---
title: "Claude の使い方を実例で伝える ― Unity の舞台で縦長ショート10本"
slug: unity-stage-for-shorts
date: 2026-11-07
day: 455
meta_description: "TikTok の縦長動画の構図を、Claude Code が Unity で13分で建てた作業部屋の舞台に置き換え、Claude と Claude Code の使い方を話す22〜29秒のショートを10本作った。X に11月17〜26日の22:00で予約した。"
tags: [YouTube, ショート, Unity, Claude, Claude Code, 縦長動画, 依頼箱]
series: "ひとり研究所の裏側"
cover: "/media/LOG455_cover.jpg"
---

**TikTok で見かけた縦長動画の構図（奥の板に1つずつ出し、2人が床の円の上に立ち、足もとに字幕を置く）を、Claude Code が Unity で13分で建てた夜の作業部屋の舞台に置き換え、Claude と Claude Code の使い方を話す22〜29秒のショートを10本作りました。** 10本は YouTube に上げる前に、X へ動画付きで、11月17日から26日まで毎晩22:00に1本ずつ予約しました。

この型ができて、次のことが変わりました。

- 次の1本は、舞台を撮り直さずに作れる。変えるのは、セリフ（narration.py）と、奥のモニターに出す画面と動き（spec.py）だけ。2本目から10本目は、この形で作った
- 見た目の決まりを部品と動画の決まり（video_rules.md の12節）に残した。モニターの中は面で組み、1画面で言うことは1つ、オレンジは「いま話している1つ」だけ。山場の演出は紙吹雪のほかに7つ用意し、毎回同じにしない
- 公開のあとに、エイイチの指摘で3つ直した。2本目からの8本で最後のサイトへの入口を入れ忘れていたのを足し、文字の被りを0.1秒ごとに機械で探す点検（check_overlap.py）を作って4本の被りを直し、締めの「公開中」の言い方を4通りから選び直した。どれも動画の決まりに書き足した
- 時間：舞台を建てて撮るまでは13分（依頼箱の記録・22:46〜22:59）。1本の書き出しは、作業環境で2本ずつ並べて動かして、8本で約16分だった（並べた回の時刻からの見積もりで、1本ずつは測っていない）。前の型と比べて何分縮んだかは、前を測っていないので分からない

きっかけは、エイイチが TikTok で見た、きよさん（「きよ | ゲーム感覚でAI×SNS」）の縦長動画「SNS運用、Claudeが終わらせた」です（画面写真で共有を受けたので、動画の URL は控えていません）。薄暗い部屋の床の円の上に作者の分身と Claude 役のキャラクターが立ち、奥の板に「スキル一覧」が1つずつ出て、下に字幕が出る。1画面に1つしか出さないので、スマホでも字が大きく読めます。ねらいは、Claude と Claude Code をふだんどう使っているかを、ひとり研究所の実例で伝えることで、この構図はその入れ物にちょうどよい形でした。中身（SNS の自動化の11個のスキル）は取り込まず、構図だけを借りて、中身と舞台はうちのものに置き換えています。

| やったこと | なぜ | 結果 |
|---|---|---|
| 参考の動画から、取り込む所と取り込まない所を決めた | バズを集める仕組みは、主指標（再現できる環境の数）と逆を向くため | 構図（舞台・奥の板・床の円・足もとの字幕）だけを借りる |
| Claude Code に Unity で舞台を建ててもらった（依頼箱 #99） | 毎回同じ舞台を使い、見た瞬間にうちの動画だと分かるようにするため | 夜の作業部屋・オレンジのふちのモニター・黄色と黒の破線の円。13分で撮れた |
| 書き出しの部品 butai_kit.py を作った | 舞台の上に、モニターの中身・2人・字幕を重ねるため | 1本目「研究のスキル一覧」29.2秒 |
| エイイチの指摘で3回直した | 距離感・モニターの雑さ・紙吹雪・床の円 | 2人を寄せ、モニターを面で組み、床の円を回した |
| 部品 butai_parts.py にまとめ、9本を作った | 2本目からはセリフと画面だけを書けば済むようにするため | Claude・Claude Code ネタで19〜24秒の9本（締めを足したあとは22〜28秒） |
| 紙吹雪のほかの演出を7つ足した | エイイチ「毎回紙吹雪なので違うパターンも考えて」 | 光の筋・集中線・波紋・スポットライト・星・光の帯・立ちのぼる四角 |
| X に動画で予約した | AIあるある と同じく、YouTube より先に X で出すため | 11/17〜26 の 22:00 |
| 最後にサイトへの入口を足した | エイイチ「うちのサイトへの動線部分がないけど？」。X の文にリンクを入れていないので、動画の中が唯一の入口 | 10本とも pixelstomotors.com で締める。すべての型の動画の決まりにした |
| 文字の被りを機械で探した | エイイチ「文字が被ってるところがある」 | check_overlap.py を作り、4本の被りを直した |
| 締めの声を作り直した | エイイチ「最後の「公開中」のイントネーションが変」 | 4通りを聞き比べ、エイイチが「公開中です！」を選んだ |

【画像】LOG455_05_ten_shorts.png 舞台型のショート10本の9秒目のコマ。どれも同じ作業部屋の舞台で、奥のモニターの中身とセリフだけが違う


## 作業ログ ― 参考の縦長動画から、取り込む所と取り込まない所を決める

### 取り込むのは構図だけで、中身の「バズ研究」は取り込まない

エイイチが、参考の動画の画面写真を9枚送ってきたところから始まりました。最初の返事では、中身（投稿・バズ研究・X収集・伸びた分析・週次レポ・リール台本・図解動画・解説動画・アフィ投稿の11個のスキル）は、すでに回っているもの（サイトの計測・LinkedIn の分析の書き出し・自動の予約・動画のスキル）と重なり、「バズを集めて寄せる」方向は MASTER の主指標（再現できる環境の数）と逆を向くので、仕組みとしては取り込まない、と答えています。そのうえで、使えるのは見せ方だと分けました。

| 参考の動画 | 取り込むか | うちでは |
|---|---|---|
| 作者の分身と Claude 役が、同じ床の円の上に立つ | 取り込む | 左にハコブくん（Claude 役）、右にエイイチ。2人とも全身で、身長の比（1.15m・1.7m）で立つ |
| 上に大きい見出し、奥の板に1つだけ、下に字幕 | 取り込む | 見出しは最後まで同じ。奥の壁のモニターに1つずつ出す |
| 上の「CLAUDE VS 手作業SNS」の体力ゲージ | 今回は入れない | 勝負の形の話ではないため |
| 薄暗い本棚の部屋・ろうそく・青緑の円 | なぞらない | 夜のロボットの作業部屋・卓上ライト・作業エリアの黄色と黒の破線の円 |
| 11個のスキル（SNS の自動化） | 取り込まない | 中身はエイイチが実際に使っている仕組みだけにする |

エイイチの答えは「これをうちに取り込める？取り込むためにはどんなことをしますか？」で、舞台をつくる・キャラを全身で立たせる・板の中身を型にする・画面の文字の位置を固定する・決まりに書く・試作を1本つくる、の6つを出しました。1本目の題材は、エイイチが「研究をひとりとClaudeで回すスキル一覧」を選びました。

### 1本目の板に並べる7つと、「いちばん助かっているもの」の理由

板に並べる候補は、研究そのもの・確かめる・記録と発信、の3つに分けて11個を出し、エイイチが「研究寄りの6〜7個に絞る」を選びました。並べたのは、運用分析・GPUで学習・依頼箱・課題地図・事実確認・声の単語帳・記録→記事の7つです。「いちばん助かっているもの」はエイイチが依頼箱を選び、理由は次の言葉でした。

> 今まで煩わしかったターミナルでの作業から解放されたこと

この言葉を、エイイチのセリフ「依頼箱。今まで煩わしかった、ターミナルの作業から、解放された！」にしています。ユーモアや実体験を Claude が作ると嘘っぽくなるので、エイイチのセリフは本人の言葉から取る、という前からの決まり（video_rules.md）どおりです。舞台は、画像で作る案と Unity で建てる案を出し、エイイチが Unity を選びました。


## 作業ログ ― 依頼箱で Claude Code に Unity の舞台を建ててもらう

### 依頼箱は、Claude が書いた依頼を Mac の見張り係が Claude Code に渡す仕組み

依頼箱は、Claude が書いた Claude Code あての依頼を置くフォルダです。Mac で動く見張り係（inbox_runner.py）が1件ずつ Claude Code に渡し、報告を「結果」に書き戻します。自動で回してよいのは、決めた作業フォルダ（ServiceSim と r2r2r-scripts）だけです。舞台は ServiceSim のプロジェクトの中に、`Assets/VideoStage/` として足してもらいました。既存のシーンと設定には触れない、と依頼文に書いています。

### 依頼箱 #99 の依頼文（全文）

```markdown
---
作業フォルダ: /Users/shimazueiichi/Robotics_UX/ServiceSim
上限時間: 90
---
# ショート動画用の「舞台」を Unity で建てて、縦長の背景として撮る

## 目的
縦長ショート（1080×1920）の背景に使う、固定カメラの「舞台」を1つ作る。キャラ（ハコブくん・エイイチの2Dの絵）と、壁のモニターの中身（文字・図）は、あとで Claude が動画側で重ねる。Unity で撮るのは背景だけ。

## 守ること
- ServiceSim の既存のシーン・スクリプト・設定（ビルド設定・時間・物理）は一切変えない。新しく作るのは `Assets/VideoStage/` の中だけ
- ArticulationBody の値には触れない（この依頼では使わない）
- 前の依頼で残った画面なしの Unity があれば、最初に終わらせる
- git は個別ファイルを指定して add する（`git add .`・`git clean` は使わない）。作業の区切りでコミットする
- 外部のアセットのダウンロードはしない。Unity の基本の形（Cube・Cylinder・Plane など）とマテリアル・ライトだけで作る

## 舞台の中身（ロボットの作業部屋）
- 夜の薄暗い作業部屋。奥の壁に棚が並び、部品箱が置いてある。奥はぼんやりした暖色の明かりで、手前は暗め
- 奥の壁の真ん中に、大きな壁掛けモニター（黒い画面・細いふち。ふちの色は #C4471F のオレンジ）。画面は少し光る濃紺（#0E1420 前後）
- 床の真ん中に、ロボットの作業エリアを示す床のマーキング（黄色と黒の破線の円。直径 約3m）。2人がこの円の上に立つ
- 右奥に作業台。台の上にロボットアームらしい形（四角と円柱を組んだ簡単なもの）と卓上ライト（暖色の点光源）
- 色は落ち着いた暗い茶・灰。派手な色はモニターのふちのオレンジと床の黄色だけ

## カメラと画面の割り振り（縦長 1080×1920・固定）
- 少し上から見下ろす（俯角 10〜15度）
- 画面の y 0〜440 は暗くしておく（見出しの文字を載せる）
- モニターの画面（光る部分）: x 80〜1000・y 460〜1080 くらいに収める
- 床の円: 中心が y 1560 前後、横幅 800〜880px の楕円に見えるように
- 円の上に立つ人の大きさ: 身長 1.7m が 画面で 520〜580px くらい

## 撮るもの（出力先: /Volumes/KIOXIA/r2r2r-data/videos/_ショート専用/研究のスキル一覧/_取り込み/舞台/）
1. `stage_bg.png` — 1080×1920、キャラなし、モニターは光った状態（中身は空）
2. `stage_bg_off.png` — 同じ構図で、モニターの電源が切れた状態（真っ黒）
3. `stage_check.png` — 同じ構図に、円の上に身長 1.7m と 1.15m の灰色のカプセルを左右に立てた確認用
4. `stage_meta.json` — 次の値（すべて画面の px・左上が原点）
   - `screen_corners`: モニターの光る部分の4隅 [左上, 右上, 右下, 左下]
   - `ring_center`, `ring_rx`, `ring_ry`: 床の円の中心と、楕円の横・縦の半径
   - `foot_left`, `foot_right`: 円の上で左右に立つ位置（足もと）の x, y
   - `px_per_m_at_ring`: 円の位置で 1m が何 px か
   - カメラの位置・向き・画角
5. `stage_loop.mp4` — 同じ構図で6秒・30fps。卓上ライトがわずかにゆらぐ・モニターの光がわずかに明滅する程度の、ほぼ止まった映像（つなげてループできるように最初と最後をそろえる）

書き出しは画面なし（-batchmode）で、カメラから RenderTexture に撮って PNG にする形でよい。

## 報告に書くこと
- 作ったファイルの一覧と、`stage_check.png` を見て、カプセルの大きさと位置が上の目安に入ったか
- 目安から外れた所と、その理由
- 最後の1行に「判定：合格／止めた（決めてほしいことあり）／失敗」
```

### Claude Code の報告（全文）

```markdown
# 結果：20261106_99_ショート用の舞台を建てて撮る

- 状態：完了
- 始めた時刻：2026-11-06 22:46
- 終わった時刻：2026-11-06 22:59（13分）
- 作業フォルダ：/Users/shimazueiichi/Robotics_UX/ServiceSim
- この続きを Claude Code で話すとき：作業フォルダで `claude --resume cac61545-2860-4b9e-9f79-22254d4d1089`
- 止められた操作：1件（いちばん下に一覧）

## Claude Code の報告

**やったこと**
- 前の依頼で残った画面なしの Unity がないか確かめました。動いていたのは Unity Hub だけで、止めるものはありませんでした。
- 舞台（夜のロボットの作業部屋）を組み立てて撮るスクリプトを書きました。置いたのは `Assets/VideoStage/Editor/VideoStageBuilder.cs` です。使ったのは基本の形とマテリアル・ライトだけで、外のアセットはダウンロードしていません。
  - 奥の壁の棚と部品箱
  - 奥の壁の真ん中のモニター（ふちはオレンジ #C4471F）
  - 床の黄色と黒の破線の円（直径 3m）
  - 右奥の作業台と、その上の小さいアーム・卓上ライト
- 画面なし（-batchmode）で、カメラから RenderTexture に撮って PNG にしました。カメラの位置と画角は、先に計算で目安に入る値を探してから決めています。
- 試しに5回撮って直しました。直した所は次の3つです。
  - モニターの画面に明かりが映り込んでいたので、明かりを受けない一様な色にしました。
  - 卓上ライトが画面の外に出ていたので、作業台を左へ寄せました。
  - アームとライトがモニターの下の端にかかっていたので、作業台を少し手前に出しました。
- 本番を KIOXIA に書き出し、ffmpeg でループの mp4 にしました。

**結果**（`stage_check.png` などを測った数）

| 項目 | 目安 | 結果 |
|---|---|---|
| 俯角 | 10〜15度 | 12度 |
| 上の 0〜440 | 暗く | 明るさの平均 0.2/255 |
| モニターの光る部分 | x 80〜1000・y 460〜1080 | x 90〜990・y 470〜1070（きっちり長方形に写る） |
| 円の中心 | y 1560 前後 | 楕円の中心 (540, 1560) |
| 円の横幅 | 800〜880px | 866px |
| 1.7m のカプセルの高さ | 520〜580px | 548px（計算では 539px） |
| 1.15m のカプセルの高さ | — | 361px |
| カプセルの位置 | 円の上の左右 | 左は x 282・右は x 794、足もとは y 約 1676 |

- モニターの光る部分の前をふさぐ物はありません（ふさがれた画素 0）。
- 画面の色は #0E1420 です（測った平均は 14,20,32）。電源を切った絵では 0,0,0 です。
- `stage_loop.mp4` は 1080×1920・30fps・6.0秒・180コマです。
  - ゆらぎは卓上ライトが ±約6%、モニターが ±約3% です。
  - 最後のコマから最初のコマへの差は、となりのコマどうしの差とほぼ同じなので、つなげても継ぎ目は見えません。
- 目安の数そのものから外れた所はありません。ただし、円の横幅と人の大きさは、円の中心の深さでは両立しませんでした。円の中心の深さでは 1m が 282px なので、1.7m は約 480px にしかなりません。そこで足もとを円の中心より 1.0m 手前に置いています（円の内側です）。
- 「円の中心 y 1560」は、画面に写った楕円の中心で合わせました。床の上の円の中心そのものは y 1510 に写ります。`stage_meta.json` には両方入れました。
- 依頼文は「git でコミット」でしたが、ServiceSim は Plastic SCM で管理しているので、いつもどおり Plastic にファイルを1つずつ指定して入れました（cs:82・cs:83）。今の保留は 0 です。

**変えたファイル**
- 新しく足したもの（Plastic cs:82・cs:83）：
  - `Assets/VideoStage/Editor/VideoStageBuilder.cs`
  - `Assets/VideoStage/VideoStage.unity`
  - `Assets/VideoStage/Materials/` のマテリアル18個
  - それぞれの .meta
- KIOXIA の `_取り込み/舞台/` に置いたもの：
  - `stage_bg.png`
  - `stage_bg_off.png`
  - `stage_check.png`
  - `stage_meta.json`
  - `stage_loop.mp4`
- 報告：`claude_code_reports/20261106_stage99.txt`
- 既存のシーン・スクリプト・設定（ビルド設定・時間・物理・品質）は変えていません。ArticulationBody も使っていません。

**決めてほしいこと**
1. 2人の立つ位置です。今は 1.7m を 520〜580px に入れるため、足もとを円の中心より 1.0m 手前に置いています。円の中心の線の上に立たせると、1.7m は約 480px になります。どちらを優先するか決めてください。
2. 全体の暗さと、ゆらぎの大きさ（卓上ライト ±6%・モニター ±3%）がこれでよいか見てください。数を変えれば、撮り直しは1回で済みます。

判定：止めた（決めてほしいことあり）

## 止められた操作

- {"tool_name": "Bash", "tool_use_id": "toolu_01EUWTWNxK5mTbJpmGca7diJ", "tool_input": {"command": "cd /Users/shimazueiichi/Robotics_UX/ServiceSim; rm -rf /tmp/vs99/try1; mkdir -p /tmp/vs99/try1; /Applications/Unity/Hub/Editor/2022.3.62f3/Unity.app/Contents/MacOS/Unity -batchmode -quit -projectPath \"$PWD\" -executeMethod VideoStage.EditorTools.VideoStageBuilder.BuildAndShoot -stageOut /tmp/vs99/try1 -stageFrames /tmp/vs99/try1/_frames -logFile /tmp/vs99/try1.log; echo exit=$?; grep -E \"error|Exc
```

「止められた操作」の1件は、試しの撮影の前に一時フォルダを消す `rm -rf` が、依頼箱の決まりで止められたものです。Claude Code はほかの方法で続け、撮影は終わっています（報告の最後の行は、決めてほしいことがあるので「止めた」）。

### 舞台を組み立てて撮る VideoStageBuilder.cs（全文）

ServiceSim の `Assets/VideoStage/Editor/VideoStageBuilder.cs` です。記事に載せるために、依頼箱 #103 で KIOXIA に写してもらいました（ServiceSim の中は変えていません）。

```csharp
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;

namespace VideoStage.EditorTools
{
    /// <summary>
    /// ショート動画（縦 1080×1920）の背景にする「ロボットの作業部屋」を組んで撮る
    /// （2026-11-06・依頼99）。
    ///
    /// ServiceSim の既存のシーン・設定には触れない。作るのは Assets/VideoStage/ の中だけ
    /// （VideoStage.unity と Materials/）。ビルド設定にも足さない。
    ///
    /// コマンド  /Applications/Unity/Hub/Editor/2022.3.62f3/Unity.app/Contents/MacOS/Unity \
    ///             -batchmode -quit -projectPath /Users/shimazueiichi/Robotics_UX/ServiceSim \
    ///             -executeMethod VideoStage.EditorTools.VideoStageBuilder.BuildAndShoot \
    ///             -stageOut <出力フォルダ> -stageFrames <ループのコマを置くフォルダ> \
    ///             -logFile <ログ>
    /// （-nographics は付けない。カメラで撮るので絵を描く装置が要る）
    ///
    /// 出すもの: stage_bg.png / stage_bg_off.png / stage_check.png / stage_meta.json と、
    ///   ループ用のコマ frame_0000.png〜frame_0179.png（mp4 にするのは ffmpeg で外から）
    /// </summary>
    public static class VideoStageBuilder
    {
        const int W = 1080, H = 1920;
        const int SuperSample = 2;      // 2倍で描いて縮める（ふちのギザギザを減らす）

        // カメラ（円の中心から手前に CamDist、高さは床の円の楕円の中心が y=RingCenterPy に来るように解く）
        const float Pitch = 12f;        // 俯角（度）
        const float VFov = 55f;         // 縦の画角（度）
        const float CamDist = 6.0f;     // 円の中心からカメラまでの水平の距離（m）
        const float RingCenterPy = 1560f;

        // 床の円（直径 3m）と、2人の足もと（円の中・手前寄りの左右）
        const float RingR = 1.5f;
        const float RingWidth = 0.10f;
        const int RingSegments = 64;
        static readonly Vector3 FootL = new Vector3(-0.75f, 0f, -1.0f);
        static readonly Vector3 FootR = new Vector3(0.75f, 0f, -1.0f);

        // 部屋
        const float WallZ = 2.9f;       // 奥の壁の面
        const float RoomHalfW = 3.6f;
        const float CeilY = 4.2f;
        const float FrontZ = -6.5f;

        // モニターの光る部分を収める枠（画面の px・左上が原点）。依頼の目安 x 80〜1000・y 460〜1080 の内側
        const float GlowLeft = 90f, GlowRight = 990f, GlowTop = 470f, GlowBottom = 1070f;
        const float Bezel = 0.035f;

        // ループ映像
        const int LoopFps = 30;
        const float LoopSec = 6f;

        static readonly string Root = "Assets/VideoStage";
        static readonly string MatDir = "Assets/VideoStage/Materials";
        static readonly string ScenePath = "Assets/VideoStage/VideoStage.unity";

        // 明るさのもと（ゆらぎで掛ける値）
        const float LampIntensity = 1.6f;
        const float GlowLightIntensity = 0.55f;
        static readonly Color ScreenOn = Hex("#0E1420");
        
        class Stage
        {
            public Camera cam;
            public Light lamp, glow;
            public Material screenMat;
            public GameObject checkL, checkR;
            public Vector3[] glowCorners;   // 左上・右上・右下・左下（世界の座標）
        }

        static Dictionary<string, Material> mats = new Dictionary<string, Material>();

        [MenuItem("VideoStage/舞台を組んで撮る（出力先を選ぶ）")]
        public static void BuildAndShootMenu()
        {
            string dir = EditorUtility.OpenFolderPanel("出力先", "", "");
            if (string.IsNullOrEmpty(dir)) return;
            Run(dir, Path.Combine(dir, "_frames"));
        }

        public static void BuildAndShoot()
        {
            string outDir = Arg("-stageOut");
            string frameDir = Arg("-stageFrames");
            if (string.IsNullOrEmpty(outDir)) throw new Exception("-stageOut がない");
            if (string.IsNullOrEmpty(frameDir)) frameDir = Path.Combine(outDir, "_frames");
            Run(outDir, frameDir);
        }

        static void Run(string outDir, string frameDir)
        {
            Directory.CreateDirectory(outDir);
            Directory.CreateDirectory(frameDir);
            var st = Build();
            EditorSceneManager.SaveScene(EditorSceneManager.GetActiveScene(), ScenePath);
            AssetDatabase.SaveAssets();

            SetMonitor(st, true, 1f);
            SetLamp(st, 1f);
            SavePng(Shoot(st.cam), Path.Combine(outDir, "stage_bg.png"));

            SetMonitor(st, false, 1f);
            SavePng(Shoot(st.cam), Path.Combine(outDir, "stage_bg_off.png"));

            SetMonitor(st, true, 1f);
            st.checkL.SetActive(true); st.checkR.SetActive(true);
            SavePng(Shoot(st.cam), Path.Combine(outDir, "stage_check.png"));
            st.checkL.SetActive(false); st.checkR.SetActive(false);

            File.WriteAllText(Path.Combine(outDir, "stage_meta.json"), Meta(st), new UTF8Encoding(false));

            // ループ：周期 6 秒で割り切れる波だけを足すので、180 コマ目は 0 コマ目と同じになる
            int n = Mathf.RoundToInt(LoopFps * LoopSec);
            for (int i = 0; i < n; i++)
            {
                float t = i / (float)LoopFps;
                SetLamp(st, 1f + Wave(t, 3, 0.030f, 0f) + Wave(t, 7, 0.020f, 1.3f) + Wave(t, 13, 0.012f, 0.4f));
                SetMonitor(st, true, 1f + Wave(t, 2, 0.020f, 0.7f) + Wave(t, 11, 0.008f, 2.1f));
                SavePng(Shoot(st.cam), Path.Combine(frameDir, string.Format("frame_{0:0000}.png", i)));
            }
            SetLamp(st, 1f); SetMonitor(st, true, 1f);
            Debug.Log("[VideoStage] done out=" + outDir + " frames=" + n);
        }

        static float Wave(float t, int cycles, float amp, float phase)
        {
            return amp * Mathf.Sin(2f * Mathf.PI * cycles * t / LoopSec + phase);
        }

        // ---------------------------------------------------------------- 組み立て
        static Stage Build()
        {
            EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
            if (!AssetDatabase.IsValidFolder(MatDir)) AssetDatabase.CreateFolder(Root, "Materials");
            mats.Clear();
            var st = new Stage();

            RenderSettings.skybox = null;
            RenderSettings.ambientMode = UnityEngine.Rendering.AmbientMode.Flat;
            RenderSettings.ambientLight = new Color(0.035f, 0.032f, 0.030f);
            RenderSettings.defaultReflectionMode = UnityEngine.Rendering.DefaultReflectionMode.Custom;
            RenderSettings.reflectionIntensity = 0.2f;
            RenderSettings.fog = false;

            // 色（暗い茶・灰。派手なのはモニターのふちと床の黄色だけ）
            Mat("Floor", Hex("#2A2724"), 0.25f);
            Mat("Wall", Hex("#3A322B"), 0.05f);
            Mat("WallSide", Hex("#2E2925"), 0.05f);
            Mat("Ceil", Hex("#141210"), 0.0f);
            Mat("Wood", Hex("#4A3727"), 0.15f);
            Mat("Metal", Hex("#6A6A6C"), 0.45f, 0.5f);
            Mat("DarkMetal", Hex("#262628"), 0.4f, 0.5f);
            Mat("BoxA", Hex("#5A4632"), 0.1f);
            Mat("BoxB", Hex("#4A4E52"), 0.15f);
            Mat("BoxC", Hex("#3E3A36"), 0.1f);
            Mat("BoxD", Hex("#5E5248"), 0.1f);
            Mat("Bezel", Hex("#C4471F"), 0.35f, 0f, Hex("#C4471F") * 0.08f);
            Mat("MonitorBack", Hex("#101012"), 0.3f);
            Mat("RingY", Hex("#E2B21C"), 0.3f, 0f, Hex("#E2B21C") * 0.06f);
            Mat("RingK", Hex("#121212"), 0.3f);
            Mat("LampHead", Hex("#3A3530"), 0.4f, 0.5f, new Color(1f, 0.72f, 0.42f) * 0.6f);
            Mat("CheckGray", Hex("#8A8A8A"), 0.2f, 0f, Hex("#8A8A8A") * 0.25f);
            // 画面は明かりを受けない一様な色（中身をあとで重ねるので、映り込みを出さない）
            var screen = Mat("Screen", ScreenOn, 0f);
            screen.shader = Shader.Find("Unlit/Color");
            screen.SetColor("_Color", ScreenOn);
            st.screenMat = screen;

            BuildRoom();
            BuildShelves();
            BuildRing();
            st.cam = BuildCamera();
            st.glowCorners = BuildMonitor(st.cam);
            st.lamp = BuildWorkbench();
            st.glow = BuildLights(st.glowCorners);
            st.checkL = CheckCapsule("check_1.70m", FootL, 1.70f);
            st.checkR = CheckCapsule("check_1.15m", FootR, 1.15f);
            st.checkL.SetActive(false); st.checkR.SetActive(false);
            return st;
        }

        static void BuildRoom()
        {
            var room = new GameObject("Room").transform;
            float w = RoomHalfW * 2f, d = WallZ - FrontZ;
            Box("Floor", room, new Vector3(0, -0.05f, (WallZ + FrontZ) / 2f), new Vector3(w, 0.1f, d), "Floor");
            Box("BackWall", room, new Vector3(0, CeilY / 2f, WallZ + 0.05f), new Vector3(w, CeilY, 0.1f), "Wall");
            Box("WallL", room, new Vector3(-RoomHalfW - 0.05f, CeilY / 2f, (WallZ + FrontZ) / 2f), new Vector3(0.1f, CeilY, d), "WallSide");
            Box("WallR", room, new Vector3(RoomHalfW + 0.05f, CeilY / 2f, (WallZ + FrontZ) / 2f), new Vector3(0.1f, CeilY, d), "WallSide");
            Box("Ceiling", room, new Vector3(0, CeilY + 0.05f, (WallZ + FrontZ) / 2f), new Vector3(w, 0.1f, d), "Ceil");
            // 壁の下の巾木と、天井の梁（暗いまま。上の見出しの所を暗く保つ）
            Box("Baseboard", room, new Vector3(0, 0.06f, WallZ - 0.01f), new Vector3(w, 0.12f, 0.03f), "DarkMetal");
            for (int i = 0; i < 3; i++)
                Box("Beam" + i, room, new Vector3(0, CeilY - 0.15f, WallZ - 0.6f - i * 2.2f), new Vector3(w, 0.3f, 0.25f), "Ceil");
            // 壁ぞいの配管
            Cyl("Pipe", room, new Vector3(0, 3.55f, WallZ - 0.12f), new Vector3(0.08f, RoomHalfW, 0.08f), "DarkMetal", new Vector3(0, 0, 90));
        }

        static void BuildShelves()
        {
            var root = new GameObject("Shelves").transform;
            var rng = new System.Random(99);
            string[] boxMats = { "BoxA", "BoxB", "BoxC", "BoxD" };
            // 奥の壁の左右に背の高い棚（モニターの外側）
            foreach (float cx in new[] { -2.85f, 2.85f })
                Shelf(root, new Vector3(cx, 0, WallZ - 0.25f), 1.3f, 2.3f, 0.45f, 5, rng, boxMats, 0f);
            // モニターの下の低い棚
            Shelf(root, new Vector3(-0.9f, 0, WallZ - 0.22f), 1.7f, 0.62f, 0.4f, 2, rng, boxMats, 0f);
            Shelf(root, new Vector3(0.9f, 0, WallZ - 0.22f), 1.7f, 0.62f, 0.4f, 2, rng, boxMats, 0f);
            // 左右の壁ぞいの棚（奥から手前へ）
            Shelf(root, new Vector3(-RoomHalfW + 0.25f, 0, 1.2f), 1.6f, 2.1f, 0.45f, 4, rng, boxMats, 90f);
            Shelf(root, new Vector3(-RoomHalfW + 0.25f, 0, -0.6f), 1.6f, 2.1f, 0.45f, 4, rng, boxMats, 90f);
            Shelf(root, new Vector3(RoomHalfW - 0.25f, 0, -0.4f), 1.6f, 2.1f, 0.45f, 4, rng, boxMats, -90f);
        }

        static void Shelf(Transform parent, Vector3 pos, float width, float height, float depth, int levels,
                          System.Random rng, string[] boxMats, float yaw)
        {
            var s = new GameObject("Shelf").transform;
            s.SetParent(parent, false);
            s.localPosition = pos;
            s.localRotation = Quaternion.Euler(0, yaw, 0);
            float post = 0.04f;
            foreach (float sx in new[] { -width / 2f + post / 2f, width / 2f - post / 2f })
                foreach (float sz in new[] { -depth / 2f + post / 2f, depth / 2f - post / 2f })
                    Box("Post", s, new Vector3(sx, height / 2f, sz), new Vector3(post, height, post), "DarkMetal");
            for (int i = 0; i < levels; i++)
            {
                float y = 0.08f + i * (height - 0.1f) / Mathf.Max(1, levels - 1);
                if (levels == 1) y = height;
                Box("Board", s, new Vector3(0, y, 0), new Vector3(width, 0.025f, depth), "Wood");
                if (i == levels - 1 && height > 1f) continue;   // いちばん上の板には何も置かない
                float space = (levels > 1 ? (height - 0.1f) / (levels - 1) : 0.4f) - 0.06f;
                float x = -width / 2f + 0.08f;
                while (true)
                {
                    float bw = 0.18f + (float)rng.NextDouble() * 0.22f;
                    if (x + bw > width / 2f - 0.08f) break;
                    if (rng.NextDouble() < 0.82)
                    {
                        float bh = Mathf.Min(space, 0.12f + (float)rng.NextDouble() * 0.2f);
                        float bd = depth * (0.6f + (float)rng.NextDouble() * 0.3f);
                        Box("PartsBox", s, new Vector3(x + bw / 2f, y + 0.0125f + bh / 2f, (float)(rng.NextDouble() - 0.5) * 0.04f),
                            new Vector3(bw, bh, bd), boxMats[rng.Next(boxMats.Length)]);
                    }
                    x += bw + 0.03f + (float)rng.NextDouble() * 0.05f;
                }
            }
        }

        static void BuildRing()
        {
            var ring = new GameObject("FloorRing").transform;
            float seg = 2f * Mathf.PI * RingR / RingSegments;
            for (int i = 0; i < RingSegments; i++)
            {
                float a = (i + 0.5f) * 2f * Mathf.PI / RingSegments;
                var p = new Vector3(Mathf.Cos(a) * RingR, 0.003f, Mathf.Sin(a) * RingR);
                var go = Box("Dash" + i, ring, p, new Vector3(RingWidth, 0.006f, seg * 1.02f), i % 2 == 0 ? "RingY" : "RingK");
                go.transform.localRotation = Quaternion.Euler(0, -a * Mathf.Rad2Deg, 0);
            }
        }

        static Camera BuildCamera()
        {
            var go = new GameObject("StageCamera");
            var cam = go.AddComponent<Camera>();
            cam.fieldOfView = VFov;
            cam.nearClipPlane = 0.1f;
            cam.farClipPlane = 40f;
            cam.clearFlags = CameraClearFlags.SolidColor;
            cam.backgroundColor = Color.black;
            cam.renderingPath = RenderingPath.DeferredShading;   // 明かりの数の上限（品質設定）を変えずに済む
            cam.allowMSAA = false;
            cam.aspect = W / (float)H;
            go.transform.rotation = Quaternion.Euler(Pitch, 0, 0);
            // 楕円の中心が y=RingCenterPy に来る高さを二分法で探す
            float lo = 0.3f, hi = 10f;
            for (int i = 0; i < 60; i++)
            {
                float h = (lo + hi) / 2f;
                go.transform.position = new Vector3(0, h, -CamDist);
                if (RingBox(cam).center.y > RingCenterPy) hi = h; else lo = h;
            }
            go.transform.position = new Vector3(0, (lo + hi) / 2f, -CamDist);
            return cam;
        }

        // モニターは画面の面がカメラの画面と平行になるように前へ傾けて掛ける（画面の上で長方形に見える）
        static Vector3[] BuildMonitor(Camera cam)
        {
            var fwd = cam.transform.forward;
            var centerRay = Ray(cam, (GlowLeft + GlowRight) / 2f, (GlowTop + GlowBottom) / 2f);
            float tc = (WallZ - 0.42f - centerRay.origin.z) / centerRay.direction.z;
            var planeP = centerRay.origin + centerRay.direction * tc;
            var plane = new Plane(-fwd, planeP);
            Vector3 Hit(float px, float py)
            {
                var r = Ray(cam, px, py);
                plane.Raycast(r, out float d);
                return r.origin + r.direction * d;
            }
            var c = new[] { Hit(GlowLeft, GlowTop), Hit(GlowRight, GlowTop), Hit(GlowRight, GlowBottom), Hit(GlowLeft, GlowBottom) };
            float gw = Vector3.Distance(c[0], c[1]), gh = Vector3.Distance(c[0], c[3]);
            var center = (c[0] + c[2]) / 2f;

            var mon = new GameObject("WallMonitor").transform;
            mon.position = center;
            mon.rotation = cam.transform.rotation;
            Box("Body", mon, new Vector3(0, 0, 0.04f), new Vector3(gw + 2f * Bezel, gh + 2f * Bezel, 0.07f), "MonitorBack");
            // ふち（オレンジ）：4本の細い棒
            Box("BezelTop", mon, new Vector3(0, gh / 2f + Bezel / 2f, -0.002f), new Vector3(gw + 2f * Bezel, Bezel, 0.012f), "Bezel");
            Box("BezelBottom", mon, new Vector3(0, -gh / 2f - Bezel / 2f, -0.002f), new Vector3(gw + 2f * Bezel, Bezel, 0.012f), "Bezel");
            Box("BezelL", mon, new Vector3(-gw / 2f - Bezel / 2f, 0, -0.002f), new Vector3(Bezel, gh, 0.012f), "Bezel");
            Box("BezelR", mon, new Vector3(gw / 2f + Bezel / 2f, 0, -0.002f), new Vector3(Bezel, gh, 0.012f), "Bezel");
            Box("Screen", mon, new Vector3(0, 0, 0f), new Vector3(gw, gh, 0.004f), "Screen");
            // 壁へのアーム（上と下の2本。傾いた分の奥行きを埋める）
            var root = new GameObject("MonitorMount").transform;
            foreach (var y in new[] { center.y + gh * 0.3f, center.y - gh * 0.3f })
            {
                float zFront = planeP.z + (y - planeP.y) * Mathf.Tan(Pitch * Mathf.Deg2Rad) + 0.08f;
                float len = Mathf.Max(0.05f, WallZ - zFront);
                Box("Arm", root, new Vector3(0, y, zFront + len / 2f), new Vector3(0.4f, 0.08f, len), "DarkMetal");
            }
            // ふちの外側の4隅（光る部分の4隅は c）
            return c;
        }

        static Light BuildWorkbench()
        {
            var b = new GameObject("Workbench").transform;
            b.position = new Vector3(1.7f, 0, 0.95f);
            b.rotation = Quaternion.Euler(0, -15f, 0);
            float tw = 1.4f, td = 0.75f, th = 0.88f;
            Box("Top", b, new Vector3(0, th, 0), new Vector3(tw, 0.05f, td), "Wood");
            foreach (float sx in new[] { -tw / 2f + 0.05f, tw / 2f - 0.05f })
                foreach (float sz in new[] { -td / 2f + 0.05f, td / 2f - 0.05f })
                    Box("Leg", b, new Vector3(sx, th / 2f, sz), new Vector3(0.05f, th, 0.05f), "DarkMetal");
            Box("LowShelf", b, new Vector3(0, 0.2f, 0), new Vector3(tw - 0.1f, 0.03f, td - 0.1f), "Wood");
            Box("BinA", b, new Vector3(-0.35f, 0.3f, 0), new Vector3(0.4f, 0.17f, 0.45f), "BoxA");
            Box("BinB", b, new Vector3(0.2f, 0.29f, 0.05f), new Vector3(0.35f, 0.15f, 0.4f), "BoxB");

            // ロボットアームらしい形（台座・肩・上腕・ひじ・前腕・手）
            var arm = new GameObject("RobotArm").transform;
            arm.SetParent(b, false);
            arm.localPosition = new Vector3(0.12f, th + 0.025f, 0.1f);
            arm.localRotation = Quaternion.Euler(0, 150f, 0);
            arm.localScale = Vector3.one * 0.62f;   // 卓上の小さいアーム（高さ約 0.37m。モニターにかからない）
            Cyl("Base", arm, new Vector3(0, 0.04f, 0), new Vector3(0.24f, 0.04f, 0.24f), "DarkMetal");
            Cyl("Turret", arm, new Vector3(0, 0.13f, 0), new Vector3(0.15f, 0.06f, 0.15f), "Metal");
            Cyl("Shoulder", arm, new Vector3(0, 0.22f, 0), new Vector3(0.12f, 0.07f, 0.12f), "DarkMetal", new Vector3(90, 0, 0));
            var upper = Box("UpperArm", arm, Vector3.zero, new Vector3(0.08f, 0.36f, 0.08f), "Metal");
            upper.transform.localPosition = new Vector3(0.07f, 0.22f + 0.17f, 0);
            upper.transform.localRotation = Quaternion.Euler(0, 0, -22f);
            Cyl("Elbow", arm, new Vector3(0.14f, 0.55f, 0), new Vector3(0.1f, 0.06f, 0.1f), "DarkMetal", new Vector3(90, 0, 0));
            var fore = Box("Forearm", arm, Vector3.zero, new Vector3(0.3f, 0.065f, 0.065f), "Metal");
            fore.transform.localPosition = new Vector3(0.27f, 0.5f, 0);
            fore.transform.localRotation = Quaternion.Euler(0, 0, -20f);
            Cyl("Wrist", arm, new Vector3(0.41f, 0.44f, 0), new Vector3(0.06f, 0.04f, 0.06f), "DarkMetal");
            Box("FingerA", arm, new Vector3(0.40f, 0.37f, 0.025f), new Vector3(0.02f, 0.08f, 0.015f), "DarkMetal");
            Box("FingerB", arm, new Vector3(0.40f, 0.37f, -0.025f), new Vector3(0.02f, 0.08f, 0.015f), "DarkMetal");

            // 卓上ライト
            var lamp = new GameObject("DeskLamp").transform;
            lamp.SetParent(b, false);
            lamp.localPosition = new Vector3(-0.5f, th + 0.025f, -0.08f);
            Cyl("LampBase", lamp, new Vector3(0, 0.012f, 0), new Vector3(0.14f, 0.012f, 0.14f), "DarkMetal");
            var pole = Box("LampPole", lamp, Vector3.zero, new Vector3(0.018f, 0.38f, 0.018f), "DarkMetal");
            pole.transform.localPosition = new Vector3(-0.03f, 0.2f, 0);
            pole.transform.localRotation = Quaternion.Euler(0, 0, 12f);
            var head = Cyl("LampHead", lamp, new Vector3(-0.1f, 0.37f, 0), new Vector3(0.14f, 0.06f, 0.14f), "LampHead", new Vector3(0, 0, 30f));
            Box("LampShade", lamp, new Vector3(-0.1f, 0.40f, 0), new Vector3(0.1f, 0.04f, 0.1f), "DarkMetal").transform.localRotation = Quaternion.Euler(0, 0, 30f);
            var lgo = new GameObject("DeskLampLight");
            lgo.transform.SetParent(lamp, false);
            lgo.transform.localPosition = new Vector3(-0.12f, 0.3f, 0);
            var l = lgo.AddComponent<Light>();
            l.type = LightType.Point;
            l.color = new Color(1f, 0.68f, 0.38f);
            l.intensity = LampIntensity;
            l.range = 2.6f;
            l.shadows = LightShadows.Soft;
            l.lightmapBakeType = LightmapBakeType.Realtime;
            return l;
        }

        static Light BuildLights(Vector3[] glow)
        {
            var root = new GameObject("Lights").transform;
            // 奥の暖色の明かり（棚のあたり・壁の下の方をぼんやり照らす）
            PointLight(root, "BackWarmL", new Vector3(-2.6f, 1.5f, WallZ - 0.9f), new Color(1f, 0.62f, 0.32f), 1.7f, 3.6f, true);
            PointLight(root, "BackWarmR", new Vector3(2.9f, 1.9f, WallZ - 0.9f), new Color(1f, 0.62f, 0.32f), 0.9f, 3.0f, false);
            PointLight(root, "UnderMonitor", new Vector3(0, 0.35f, WallZ - 0.7f), new Color(1f, 0.6f, 0.3f), 1.0f, 2.4f, false);
            PointLight(root, "SideWarmL", new Vector3(-RoomHalfW + 0.9f, 1.8f, 0.4f), new Color(1f, 0.6f, 0.32f), 0.45f, 2.6f, false);
            // 円の上の弱い明かり（手前は暗めのまま、円と立つ所だけ少し見える）
            var s = new GameObject("RingSpot");
            s.transform.SetParent(root, false);
            s.transform.position = new Vector3(0, CeilY - 0.2f, -0.6f);
            s.transform.rotation = Quaternion.Euler(90, 0, 0);
            var sl = s.AddComponent<Light>();
            sl.type = LightType.Spot;
            sl.spotAngle = 75f;
            sl.innerSpotAngle = 30f;
            sl.color = new Color(1f, 0.84f, 0.68f);
            sl.intensity = 0.9f;
            sl.range = 6f;
            sl.shadows = LightShadows.Soft;
            sl.lightmapBakeType = LightmapBakeType.Realtime;
            // モニターの光が前に落ちる（青み）
            var gc = (glow[0] + glow[2]) / 2f;
            return PointLight(root, "MonitorGlow", gc + new Vector3(0, -0.4f, -1.0f), new Color(0.35f, 0.45f, 0.85f), GlowLightIntensity, 4.0f, false);
        }

        static Light PointLight(Transform parent, string name, Vector3 pos, Color c, float intensity, float range, bool shadows)
        {
            var go = new GameObject(name);
            go.transform.SetParent(parent, false);
            go.transform.position = pos;
            var l = go.AddComponent<Light>();
            l.type = LightType.Point;
            l.color = c; l.intensity = intensity; l.range = range;
            l.shadows = shadows ? LightShadows.Soft : LightShadows.None;
            l.lightmapBakeType = LightmapBakeType.Realtime;
            return l;
        }

        static GameObject CheckCapsule(string name, Vector3 foot, float height)
        {
            var go = GameObject.CreatePrimitive(PrimitiveType.Capsule);
            go.name = name;
            UnityEngine.Object.DestroyImmediate(go.GetComponent<Collider>());
            float r = height * 0.13f;
            go.transform.position = foot + new Vector3(0, height / 2f, 0);
            go.transform.localScale = new Vector3(r * 2f, height / 2f, r * 2f);
            go.GetComponent<Renderer>().sharedMaterial = mats["CheckGray"];
            return go;
        }

        // ---------------------------------------------------------------- 撮る
        static void SetMonitor(Stage st, bool on, float k)
        {
            st.screenMat.SetColor("_Color", on ? ScreenOn * k : Color.black);
            st.glow.enabled = on;
            st.glow.intensity = GlowLightIntensity * k;
        }

        static void SetLamp(Stage st, float k) { st.lamp.intensity = LampIntensity * k; }

        static Texture2D Shoot(Camera cam)
        {
            var big = RenderTexture.GetTemporary(W * SuperSample, H * SuperSample, 24, RenderTextureFormat.ARGB32, RenderTextureReadWrite.Default);
            var small = RenderTexture.GetTemporary(W, H, 0, RenderTextureFormat.ARGB32, RenderTextureReadWrite.Default);
            big.filterMode = FilterMode.Bilinear;
            cam.targetTexture = big;
            cam.Render();
            cam.targetTexture = null;
            Graphics.Blit(big, small);   // 2倍→1倍。1倍の画素の中心は2×2の真ん中なので4つの平均になる
            var prev = RenderTexture.active;
            RenderTexture.active = small;
            var tex = new Texture2D(W, H, TextureFormat.RGB24, false);
            tex.ReadPixels(new Rect(0, 0, W, H), 0, 0);
            tex.Apply();
            RenderTexture.active = prev;
            RenderTexture.ReleaseTemporary(big);
            RenderTexture.ReleaseTemporary(small);
            return tex;
        }

        static void SavePng(Texture2D tex, string path)
        {
            File.WriteAllBytes(path, tex.EncodeToPNG());
            UnityEngine.Object.DestroyImmediate(tex);
        }

        // ---------------------------------------------------------------- 数（画面の px・左上が原点）
        static Vector2 Px(Camera cam, Vector3 world)
        {
            var v = cam.WorldToViewportPoint(world);
            return new Vector2(v.x * W, (1f - v.y) * H);
        }

        // 床の円（線の真ん中の半径 1.5m）を 720 点で画面に写した外接の箱
        static Rect RingBox(Camera cam)
        {
            float minX = 1e9f, maxX = -1e9f, minY = 1e9f, maxY = -1e9f;
            for (int i = 0; i < 720; i++)
            {
                float a = i * 2f * Mathf.PI / 720f;
                var p = Px(cam, new Vector3(Mathf.Cos(a) * RingR, 0, Mathf.Sin(a) * RingR));
                minX = Mathf.Min(minX, p.x); maxX = Mathf.Max(maxX, p.x);
                minY = Mathf.Min(minY, p.y); maxY = Mathf.Max(maxY, p.y);
            }
            return Rect.MinMaxRect(minX, minY, maxX, maxY);
        }

        static Ray Ray(Camera cam, float px, float py)
        {
            return cam.ViewportPointToRay(new Vector3(px / W, 1f - py / H, 0));
        }

        static string Meta(Stage st)
        {
            var cam = st.cam;
            var ci = CultureInfo.InvariantCulture;
            string F(float v) => v.ToString("0.0", ci);
            string F3(float v) => v.ToString("0.###", ci);
            string P(Vector2 p) => "[" + F(p.x) + ", " + F(p.y) + "]";
            string V(Vector3 p) => "[" + F3(p.x) + ", " + F3(p.y) + ", " + F3(p.z) + "]";

            var box = RingBox(cam);
            var ringC = box.center;
            var ringFloorC = Px(cam, Vector3.zero);
            float pxPerM = Px(cam, new Vector3(0.5f, 0, 0)).x - Px(cam, new Vector3(-0.5f, 0, 0)).x;
            float pxPerMFeet = Px(cam, FootR + new Vector3(0.5f, 0, 0)).x - Px(cam, FootR + new Vector3(-0.5f, 0, 0)).x;
            var fl = Px(cam, FootL); var fr = Px(cam, FootR);
            float hL170 = fl.y - Px(cam, FootL + Vector3.up * 1.70f).y;
            float hR115 = fr.y - Px(cam, FootR + Vector3.up * 1.15f).y;
            float hR170 = fr.y - Px(cam, FootR + Vector3.up * 1.70f).y;
            float hFov = 2f * Mathf.Atan(Mathf.Tan(VFov * Mathf.Deg2Rad / 2f) * W / H) * Mathf.Rad2Deg;
            var g = st.glowCorners;

            var sb = new StringBuilder();
            sb.Append("{\n");
            sb.Append("  \"image_size\": [" + W + ", " + H + "],\n");
            sb.Append("  \"origin\": \"top-left, px\",\n");
            sb.Append("  \"screen_corners\": [" + P(Px(cam, g[0])) + ", " + P(Px(cam, g[1])) + ", " + P(Px(cam, g[2])) + ", " + P(Px(cam, g[3])) + "],\n");
            sb.Append("  \"screen_corners_order\": \"top-left, top-right, bottom-right, bottom-left\",\n");
            sb.Append("  \"ring_center\": " + P(ringC) + ",\n");
            sb.Append("  \"ring_rx\": " + F(box.width / 2f) + ",\n");
            sb.Append("  \"ring_ry\": " + F(box.height / 2f) + ",\n");
            sb.Append("  \"ring_center_floor_point\": " + P(ringFloorC) + ",\n");
            sb.Append("  \"ring_note\": \"ring_center/rx/ry = bounding box of the projected ring (line center, radius 1.5 m, line width 0.10 m). ring_center_floor_point = projection of the ring's center on the floor\",\n");
            sb.Append("  \"foot_left\": " + P(fl) + ",\n");
            sb.Append("  \"foot_right\": " + P(fr) + ",\n");
            sb.Append("  \"foot_world_m\": {\"left\": " + V(FootL) + ", \"right\": " + V(FootR) + "},\n");
            sb.Append("  \"px_per_m_at_ring\": " + F(pxPerM) + ",\n");
            sb.Append("  \"px_per_m_at_feet\": " + F(pxPerMFeet) + ",\n");
            sb.Append("  \"person_height_px\": {\"left_1.70m\": " + F(hL170) + ", \"right_1.15m\": " + F(hR115) + ", \"right_1.70m\": " + F(hR170) + "},\n");
            sb.Append("  \"camera\": {\n");
            sb.Append("    \"position_m\": " + V(cam.transform.position) + ",\n");
            sb.Append("    \"rotation_euler_deg\": " + V(cam.transform.eulerAngles) + ",\n");
            sb.Append("    \"pitch_down_deg\": " + F3(Pitch) + ",\n");
            sb.Append("    \"vertical_fov_deg\": " + F3(VFov) + ",\n");
            sb.Append("    \"horizontal_fov_deg\": " + F3(hFov) + ",\n");
            sb.Append("    \"near_far_m\": [" + F3(cam.nearClipPlane) + ", " + F3(cam.farClipPlane) + "],\n");
            sb.Append("    \"axes\": \"Unity world, y up, z toward the back wall, meters. ring center at (0,0,0)\"\n");
            sb.Append("  },\n");
            sb.Append("  \"loop\": {\"fps\": " + LoopFps + ", \"seconds\": " + F3(LoopSec) + ", \"frames\": " + Mathf.RoundToInt(LoopFps * LoopSec) + "}\n");
            sb.Append("}\n");
            return sb.ToString();
        }

        // ---------------------------------------------------------------- 小道具
        static Material Mat(string name, Color albedo, float smooth, float metal = 0f, Color? emission = null)
        {
            string path = MatDir + "/" + name + ".mat";
            var m = AssetDatabase.LoadAssetAtPath<Material>(path);
            if (m == null)
            {
                m = new Material(Shader.Find("Standard"));
                AssetDatabase.CreateAsset(m, path);
            }
            m.shader = Shader.Find("Standard");
            m.SetColor("_Color", albedo);
            m.SetFloat("_Glossiness", smooth);
            m.SetFloat("_Metallic", metal);
            if (emission.HasValue)
            {
                m.EnableKeyword("_EMISSION");
                m.globalIlluminationFlags = MaterialGlobalIlluminationFlags.None;
                m.SetColor("_EmissionColor", emission.Value);
            }
            else
            {
                m.DisableKeyword("_EMISSION");
                m.SetColor("_EmissionColor", Color.black);
            }
            EditorUtility.SetDirty(m);
            mats[name] = m;
            return m;
        }

        static GameObject Box(string name, Transform parent, Vector3 localPos, Vector3 size, string mat)
        {
            return Prim(PrimitiveType.Cube, name, parent, localPos, size, mat, Vector3.zero);
        }

        static GameObject Cyl(string name, Transform parent, Vector3 localPos, Vector3 scale, string mat, Vector3? euler = null)
        {
            return Prim(PrimitiveType.Cylinder, name, parent, localPos, scale, mat, euler ?? Vector3.zero);
        }

        static GameObject Prim(PrimitiveType t, string name, Transform parent, Vector3 localPos, Vector3 scale, string mat, Vector3 euler)
        {
            var go = GameObject.CreatePrimitive(t);
            go.name = name;
            UnityEngine.Object.DestroyImmediate(go.GetComponent<Collider>());
            if (parent != null) go.transform.SetParent(parent, false);
            go.transform.localPosition = localPos;
            go.transform.localRotation = Quaternion.Euler(euler);
            go.transform.localScale = scale;
            go.GetComponent<Renderer>().sharedMaterial = mats[mat];
            return go;
        }

        static Color Hex(string h)
        {
            ColorUtility.TryParseHtmlString(h, out var c);
            return c;
        }

        static string Arg(string name)
        {
            var a = Environment.GetCommandLineArgs();
            for (int i = 0; i < a.Length - 1; i++) if (a[i] == name) return a[i + 1];
            return null;
        }
    }
}
```

### 動画の側が使う位置の値 stage_meta.json

```json
{
  "image_size": [1080, 1920],
  "origin": "top-left, px",
  "screen_corners": [[90.0, 470.0], [990.0, 470.0], [990.0, 1070.0], [90.0, 1070.0]],
  "screen_corners_order": "top-left, top-right, bottom-right, bottom-left",
  "ring_center": [540.0, 1560.0],
  "ring_rx": 433.4,
  "ring_ry": 222.2,
  "ring_center_floor_point": [540.0, 1510.2],
  "ring_note": "ring_center/rx/ry = bounding box of the projected ring (line center, radius 1.5 m, line width 0.10 m). ring_center_floor_point = projection of the ring's center on the floor",
  "foot_left": [291.7, 1675.6],
  "foot_right": [788.3, 1675.6],
  "foot_world_m": {"left": [-0.75, 0, -1], "right": [0.75, 0, -1]},
  "px_per_m_at_ring": 281.6,
  "px_per_m_at_feet": 331.0,
  "person_height_px": {"left_1.70m": 539.2, "right_1.15m": 357.0, "right_1.70m": 539.2},
  "camera": {
    "position_m": [0, 3.273, -6],
    "rotation_euler_deg": [12, 0, 0],
    "pitch_down_deg": 12,
    "vertical_fov_deg": 55,
    "horizontal_fov_deg": 32.642,
    "near_far_m": [0.1, 40],
    "axes": "Unity world, y up, z toward the back wall, meters. ring center at (0,0,0)"
  },
  "loop": {"fps": 30, "seconds": 6, "frames": 180}
}
```

【画像】LOG455_01_unity_stage.png Unity で撮った舞台。左はキャラのいない背景、右は身長1.7mと1.15mの灰色のカプセルを立てた確かめの画像

### 2人の立つ位置は、撮り直さずに動画の側で決めた

報告の「決めてほしいこと」の1つ目は、2人の立つ位置でした。円の中心の深さでは 1m が 282px なので、1.7m は約 480px にしかならず、目安（520〜580px）に届きません。Claude Code は足もとを円の中心より 1.0m 手前に置いていました。

これは Unity を撮り直さず、動画の側で決めました。足もとの y を 1525 に上げて円の奥寄りに立たせ、大きさを 0.88 倍にしています（奥に立つほど小さく見えるのと合うため）。2人の間は、版2でエイイチの「ハコブくんとオレの距離感もっと近くて良さそう」を受けて、580px から 300px に詰めました。


## 作業ログ ― 舞台型の書き出し butai_kit.py を作る

### AIあるある の書き出し aiaru_kit.py から変えた所

書き出しは、ショート「AIあるある」の aiaru_kit.py を写して作りました。2人とも全身・左にハコブくん・右にエイイチ・字幕はハコブくんが水色でエイイチが白、という所は同じです。変えたのは次の所です。

| 変えた所 | aiaru_kit.py | butai_kit.py |
|---|---|---|
| 背景 | 茶色のグラデーション | Unity の舞台（stage_bg.png）。モニターが消えた絵からついた絵に切り替える |
| 画面の中身 | 画面いっぱいに置く確認の窓 | モニターの光る部分（900×600 の枠）の中だけに置く。stage_meta.json の4隅から位置を決める |
| 2人の大きさと位置 | 決まった px | 舞台の 1m の px から決める（ハコブくん 1.15m・エイイチ 1.7m）。エイイチのドット絵は整数倍で拡大 |
| 字幕 | 白い字に黒いふち（y 1150） | 黒い箱に1行ずつ（y 1590・足もとの下） |
| 上の見出しの下 | なし | 見出しが読めるよう、上を暗くする帯 |
| モニターの外の演出 | なし | spec.py の OVER で、床の円・紙吹雪・ほかの演出を重ねる |

### butai_kit.py（全文）

```python
# -*- coding: utf-8 -*-
"""ショート「舞台型」の書き出し（2026-11-06「研究のスキル一覧」で作った。元は AIあるある の aiaru_kit.py）。
舞台（Unity で撮った背景 stage_bg.png と位置の値 stage_meta.json）の上に、奥のモニターの中身・2人（全身・身長の比）・字幕の黒い箱を重ねる。
各回の作業フォルダに narration.py と spec.py（NAME・HEAD・scene(T,S)・FACE・POSE・LEAN・SE・ENDING・LEAD・STAGE＝舞台のフォルダ）を置き、そこで
  P2M_BASE=<_制作ベース> python3 ../舞台型_共通/butai_kit.py [--still 秒,…]

以下は元の aiaru_kit.py の説明:
ショート「AIあるある」の共通の書き出し（1080×1920・2026-11-02「確認いらん」で作り、2本目から共通にした）。
各回の作業フォルダに narration.py と spec.py（NAME・NO・HEAD・scene(T,S)・FACE・POSE・LEAN・SE・ENDING・LEAD）を置き、そこで
  P2M_BASE=<_制作ベース> python3 ../AIあるある_共通/aiaru_kit.py [--still 秒,…]

書き写した元: 動画/現場で扱えるか/kabe_short.py（考察回のショート。2人とも全身・左下ハコブくん・右下エイイチ）
この回で足したもの:
  - 確認の窓（回数つき）が、ハコブくんが確認するたびに積み重なる
  - エイイチの心の声を、縦書きの黒い札で叩きつける（参考: AI と酔っぱらいの縦長動画のツッコミ）。言葉はエイイチが決めた
  - ハコブくんは確認するたびに大きく・前に乗り出す
  - 最後にエイイチがコケる（全身の絵を足もとを軸に倒す）
  - 字幕の色: ハコブくんは水色、エイイチは白（マンガのショートと同じ決まり）
使い方: P2M_BASE=<_制作ベース> python3 kakunin_short.py [--still 秒,秒,...]
"""
import os, sys, json, wave, subprocess, html as _html
import numpy as np
from PIL import Image, ImageFilter, ImageOps
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.getcwd())
import kabe_kit as K
from kabe_kit import ease, blend, rotate_about, hk, FPS, SR, shake_js
sys.path.insert(1, K.BASE)
import short_kit as SK
from scene_kit import LOGO

W, H = 1080, 1920
SANS = "font-family:'Noto Sans CJK JP',sans-serif;"
FOOT = 1640
SUB_MID, SUB_CX, SUB_MAXC = 1560, 540, 13
EI_SCALE = 3
HK_SMALL_H, HK_X, HK_BIG_H = 345, 10, 760
SUB_BOX = True
EI_POSES = {1: "立つ", 2: "腕組み", 3: "驚く", 4: "笑う"}
HK_BLUE = "rgb(120,200,255)"

VCSS = """
html, body { width:1080px; height:1920px; }
.f { width:1080px; height:1920px; background:#141311; }
.stage { position:absolute; inset:0; background-size:cover; }
.scr { position:absolute; overflow:hidden; font-family:'Noto Sans CJK JP',sans-serif; color:#f5f3ee; }
.scr .off { position:absolute; inset:0; background:#050608; z-index:20; }
@keyframes scrOn { 0% { opacity:1; } 30% { opacity:.2; } 45% { opacity:.9; } 100% { opacity:0; } }
.vhead { position:absolute; left:60px; right:60px; top:170px; height:270px; display:flex; flex-direction:column; justify-content:center; align-items:center; text-align:center; z-index:5; }
.vhead img { height:54px; align-self:flex-start; margin-bottom:18px; filter:invert(1) drop-shadow(0 0 6px rgba(0,0,0,.8)); opacity:.9; }
.vhead .q { font-family:'Noto Sans CJK JP',sans-serif; font-size:84px; font-weight:900; line-height:1.2; color:#f5f3ee; text-shadow:0 0 18px rgba(0,0,0,.9), 0 4px 0 #141311; }
.dlg { position:absolute; background:#f5f3ee; border-radius:22px; box-shadow:0 26px 50px rgba(0,0,0,.55); overflow:hidden; opacity:0; font-family:'Noto Sans CJK JP',sans-serif; color:#22201c; }
.dlg .bar { background:#22201c; color:#f5f3ee; font-size:32px; font-weight:700; padding:14px 28px; display:flex; align-items:center; gap:14px; }
.dlg .bar i { font-style:normal; width:44px; height:44px; border-radius:50%; background:#ffd84a; color:#22201c; display:flex; align-items:center; justify-content:center; font-weight:900; }
.dlg .body { font-size:50px; font-weight:800; line-height:1.35; text-align:center; padding:34px 30px 22px; }
.dlg .btns { display:flex; gap:24px; justify-content:center; padding:0 30px 30px; }
.dlg .btns b { font-size:36px; padding:12px 0; width:220px; text-align:center; border-radius:14px; border:4px solid #22201c; }
.dlg .btns b.y { background:#22201c; color:#f5f3ee; }
.dlg.red .bar { background:#c4472a; }
@keyframes dlgIn { 0% { opacity:0; transform:var(--r) scale(1.5); } 70% { opacity:1; transform:var(--r) scale(.96); } 100% { opacity:1; transform:var(--r) scale(1); } }
.tk { position:absolute; writing-mode:vertical-rl; background:#0d0d0d; color:#fff; border:6px solid #fff; font-family:'Noto Sans CJK JP',sans-serif;
      font-weight:900; line-height:1.12; padding:20px 12px; box-shadow:0 14px 30px rgba(0,0,0,.6); opacity:0; z-index:8; text-orientation:upright; }
.dlg .body.eng { font-family:'Noto Sans','Noto Sans CJK JP',sans-serif; font-size:30px; font-weight:500; line-height:1.4; text-align:left; padding:24px 34px 16px; }
.dlg .note { position:absolute; right:22px; top:12px; font-size:24px; color:#bdb7ab; font-weight:700; }
.chk { font-size:44px; font-weight:900; color:#f5f3ee; text-shadow:0 4px 0 #141311; }
@keyframes dim { to { filter:brightness(.42); } }
@keyframes tkIn { 0% { opacity:0; transform:rotate(4deg) scale(2.2); } 70% { opacity:1; transform:rotate(4deg) scale(.95); } 100% { opacity:1; transform:rotate(4deg) scale(1); } }
"""


def vpage(head, body, bg_html="", t_on=0.0):
    st = "file://" + os.path.join(STAGE, getattr(SP, "BG_NAME", "stage_bg") + ".png")
    off = "file://" + os.path.join(STAGE, getattr(SP, "BG_NAME", "stage_bg").replace("stage_bg", "stage_bg_off") + ".png")
    k = min(SCR["w"] / 900, SCR["h"] / 600)
    body = f'<div style="position:absolute;left:50%;top:50%;width:900px;height:600px;transform:translate(-50%,-50%) scale({k:.4f})">{body}</div>'
    scr = (f'<div class="scr" style="left:{SCR["x"]}px;top:{SCR["y"]}px;width:{SCR["w"]}px;height:{SCR["h"]}px">{body}'
           f'<div class="off" style="animation:scrOn .45s linear {t_on:.3f}s forwards"></div></div>')
    stage = (f'<div class="stage" style="background-image:url(\'{st}\')"></div>'
             f'<div class="stage" style="background-image:url(\'{off}\');animation:hide .3s linear {t_on:.3f}s forwards"></div>')
    top = '<div style="position:absolute;left:0;right:0;top:0;height:470px;background:linear-gradient(rgba(10,10,10,.75),rgba(10,10,10,.35) 75%,rgba(10,10,10,0));z-index:4"></div>'
    extra = getattr(SP, "OVER", None)
    return K.page(stage + bg_html + scr + top + (extra(T_ABS) if extra else "") + f'<div class="vhead"><img src="{LOGO}"><div class="q">{head}</div></div>', VCSS)


def bg_layer(T):
    """背景の写真（Gemini で作成）。最初は明るく見せ、話の中身が出る所で暗くして、窓と字幕を読みやすくする"""
    if not BG:
        return ""
    p = "file://" + os.path.abspath(BG["file"])
    t = T(*BG.get("at", (0, 0.0)))
    v = BG.get("veil", 0.62)
    return (f'<div style="position:absolute;inset:0;background:url(\'{p}\') center/cover no-repeat"></div>'
            '<div style="position:absolute;left:0;right:0;top:0;height:560px;background:linear-gradient(rgba(20,19,17,.8),rgba(20,19,17,.4) 70%,rgba(20,19,17,0))"></div>'
            f'<style>@keyframes bgv {{ to {{ opacity:{v}; }} }}</style>'
            f'<div style="position:absolute;inset:0;background:#141311;opacity:.12;animation:bgv .5s ease {t:.3f}s forwards"></div>'
            '<div style="position:absolute;right:40px;top:446px;font-family:\'Noto Sans CJK JP\';font-size:22px;color:#fff;opacity:.85;text-shadow:0 0 6px #000;z-index:7">背景は生成AI（Gemini）で作成</div>')


import importlib.util as _ilu


def _here(name):
    """作業フォルダ（いまのフォルダ）の name.py を読む（_制作ベース に同じ名前があるため、場所を決めて読む）"""
    sp = _ilu.spec_from_file_location(name + "_here", os.path.join(os.getcwd(), name + ".py"))
    m = _ilu.module_from_spec(sp); sp.loader.exec_module(m)
    return m


SP = _here("spec")
NAME, LEAD, FACE, POSE, LEAN, SE = SP.NAME, SP.LEAD, SP.FACE, SP.POSE, SP.LEAN, SP.SE
ENDING = getattr(SP, "ENDING", dict(kind="fall"))
INTRO = getattr(SP, "INTRO", "hop")      # 冒頭の大きなハコブくんの動き: hop / rise / dash / drop / spin（2026-11-02 エイイチ「いろんな動き、いろんな向きに」）
MOVES = getattr(SP, "MOVES", [])         # [(文, 秒, 動き, 長さ)] 動き: hop / shake / tilt / bow / spin / turn / dash / back / jump
BG = getattr(SP, "BG", None)
# --- 舞台（Unity で撮った背景と位置の値）---
STAGE = os.path.abspath(getattr(SP, "STAGE", "stage"))
META = json.load(open(os.path.join(STAGE, "stage_meta.json")))
PPM = META["person_height_px"]["right_1.70m"] / 1.7 if "person_height_px" in META else META["px_per_m_at_ring"]   # 足もとの位置での 1m
_c = META["screen_corners"]
SCR = dict(x=round(min(p[0] for p in _c)), y=round(min(p[1] for p in _c)), w=round(max(p[0] for p in _c) - min(p[0] for p in _c)), h=round(max(p[1] for p in _c) - min(p[1] for p in _c)))
HK_FOOT = [round(v) for v in META["foot_left"]]; EI_FOOT = [round(v) for v in META["foot_right"]]
HK_FOOT = list(getattr(SP, "FOOT_L", HK_FOOT)); EI_FOOT = list(getattr(SP, "FOOT_R", EI_FOOT))   # 奥に立たせるときは SIZE も小さくする
HK_SMALL_H = round(1.15 * PPM * getattr(SP, "SIZE", 1.0))
FOOT = HK_FOOT[1]
SUB_MID = getattr(SP, "SUB_MID", round(META["ring_center"][1]))
T_ON = getattr(SP, "T_ON", None)   # (文, 秒) モニターがつく所             # dict(file=背景の写真, at=(文, 秒) 暗くし始める所, veil=暗さ)。話によって写真を敷く


def vpages10(text):
    out = []
    for part in text.split("｜"):
        if not part:
            continue
        ls = SK.wrap(part, 15 if getattr(SP, "SUB_ONE_LINE", False) else SUB_MAXC)
        if ls is None:
            out.append(None); continue
        while ls:
            n = len(ls)
            k = 1 if getattr(SP, "SUB_ONE_LINE", False) else (3 if n == 3 or n >= 5 else min(n, 2))
            out.append("\n".join(ls[:k])); ls = ls[k:]
    return out


def build(stills=None):
    N = _here("narration")
    idx_main = json.load(open("tts/index.json"))
    ei_main = set(N.EI_LINES)
    lead = round(LEAD * FPS)
    segs, t = [], lead
    for k, sg in enumerate(N.SEGS):
        _, face, text, pause = N.parse(sg)
        o = idx_main[k]
        frames = round((o["dur"] + pause) * FPS)
        segs.append(dict(k=k, i=k, text=text, face=face, start=t, speech=round(o["dur"] * FPS), frames=frames, ei=k in ei_main))
        t += frames
    total = t
    if total / FPS > 30.0:
        sys.exit(f"{total / FPS:.1f}秒あります（30秒以内）")

    def Tf(k, off=0.0):
        if k == "end":
            s = segs[-1]; return (s["start"] + s["speech"]) / FPS + 0.15 + off
        return (segs[k]["start"] / FPS if k >= 0 else 0.0) + off
    Sf = lambda k: segs[k]["speech"] / FPS
    global T_ABS
    T_ABS = Tf
    html = vpage(SP.HEAD, SP.scene(Tf, Sf), bg_layer(Tf), Tf(*T_ON) if T_ON else 0.0)
    os.makedirs("out/png", exist_ok=True)

    from playwright.sync_api import sync_playwright
    if stills:
        with sync_playwright() as p:
            br = p.chromium.launch(); pg = br.new_page(viewport={"width": W, "height": H})
            open("_block.html", "w", encoding="utf-8").write(html)
            pg.goto("file://" + os.path.abspath("_block.html")); pg.evaluate("document.fonts.ready"); pg.wait_for_timeout(300)
            for s in stills:
                pg.evaluate(f"window.setT({s})"); pg.screenshot(path=f"out/png/still_{s:05.2f}.png")
            br.close()
        # 被りの点検用（check_overlap.py）: 0.1秒ごとのエイイチの絵の範囲（頭の上の 40% だけ）
        sc = max(2, round(1.7 * PPM * getattr(SP, "SIZE", 1.0) / Image.open("src/ei/エイイチ_全身_立つ_170px_v2.png").getbbox()[3]))
        ims_ = {k_: Image.open(f"src/ei/エイイチ_全身_{nm}_170px_v2.png") for k_, nm in EI_POSES.items()}
        poses_ = sorted((Tf(k, a), p_) for k, a, p_ in POSE)
        t_end_ = Tf(*ENDING["at"]) if "at" in ENDING else Tf("end", 0.0)
        rects = []
        for i in range(int(total / FPS * 10) + 1):
            t = i / 10; pose = 1
            for a_, p_ in poses_:
                if t >= a_:
                    pose = p_
            im_ = ims_[pose]
            l, tp, r, b = [v * sc for v in im_.getbbox()]
            u = t - t_end_
            jy = int(-120 * abs(np.sin(u * 7.0)) * np.exp(-u * 1.6)) if (ENDING["kind"] == "jump" and 0 <= u < 2.0) else 0
            ex = EI_FOOT[0] - im_.width * sc / 2
            ey = EI_FOOT[1] - im_.height * sc + jy - sc   # 喋るときの上下（1段）も見込む
            rects.append([t, ex + l, ey + tp, ex + r, ey + tp + (b - tp) * 0.4])
        json.dump(rects, open("out/ei_rects.json", "w"))
        print("総尺", round(total / FPS, 2)); return

    # --- 声 ---
    pcm = np.zeros(total * SR // FPS + SR, np.int16)
    for s in segs:
        with wave.open(f"tts/{s['i']:03d}.wav") as w:
            a = np.frombuffer(w.readframes(w.getnframes()), dtype=np.int16)
        a = a[:s["frames"] * SR // FPS]
        s0 = s["start"] * SR // FPS
        pcm[s0:s0 + len(a)] = a
    pcm = pcm[:total * SR // FPS]

    # --- 字幕（ハコブくんは水色・エイイチは白）と画面 ---
    one = getattr(SP, "SUB_ONE_LINE", False)
    SUB_FS_, SUB_MAXW_ = (48, 800) if one else (SK.SUB_FS, SK.SUB_MAXW)
    subs = []
    with sync_playwright() as p:
        br = p.chromium.launch()
        pg = br.new_page(viewport={"width": W, "height": H})
        for s in segs:
            fill = "#fff" if s["ei"] else HK_BLUE
            SUBCSS = (f"*{{margin:0;padding:0}}html,body{{width:{W}px;height:{H}px;background:transparent}}svg{{position:absolute;left:0;top:0}}"
                      f".t{{font-family:'Noto Sans CJK JP',sans-serif;font-size:{SUB_FS_}px;font-weight:800;fill:{fill};stroke:#0D0D0D;stroke-width:12px;stroke-linejoin:round;paint-order:stroke fill}}")
            pages_ = vpages10(s["text"])
            n, tot, acc = len(pages_), sum(len(x or "") for x in pages_), 0
            for pk, ptxt in enumerate(pages_):
                if ptxt is None:
                    sys.exit(f"字幕が折れない: {s['text']}")
                svg = ''.join(f'<text x="{SUB_CX}" text-anchor="middle" class="t">{_html.escape(ln)}</text>' for ln in ptxt.split("\n"))
                pg.set_content(f'<!doctype html><html><head><meta charset="utf-8"><style>{SUBCSS}</style></head><body><svg width="{W}" height="{H}">{svg}</svg></body></html>')
                pg.evaluate(f"""() => {{ const ts=[...document.querySelectorAll('text.t')]; const n=ts.length; let fs={SUB_FS_};
                  for (const t of ts) {{ const w=t.getComputedTextLength(); if (w>{SUB_MAXW_}) fs=Math.min(fs, Math.floor({SUB_FS_}*{SUB_MAXW_}/w)); }}
                  ts.forEach((t,k)=>{{ t.style.fontSize=fs+'px'; t.setAttribute('y', Math.round({SUB_MID}+(k-(n-1)/2)*{SK.SUB_LH}+fs*0.36)); }});
                  if ({'true' if SUB_BOX else 'false'}) {{ let x0=1e9,y0=1e9,x1=-1e9,y1=-1e9; for (const t of ts) {{ const b=t.getBBox(); x0=Math.min(x0,b.x); y0=Math.min(y0,b.y); x1=Math.max(x1,b.x+b.width); y1=Math.max(y1,b.y+b.height); }}
                    const r=document.createElementNS('http://www.w3.org/2000/svg','rect'); const px=34, py=18;
                    r.setAttribute('x',x0-px); r.setAttribute('y',y0-py); r.setAttribute('width',x1-x0+2*px); r.setAttribute('height',y1-y0+2*py); r.setAttribute('rx',10);
                    r.setAttribute('fill','rgba(10,10,10,.88)'); r.setAttribute('stroke','rgba(245,243,238,.35)'); r.setAttribute('stroke-width','3');
                    ts[0].parentNode.insertBefore(r, ts[0]); ts.forEach(t=>t.style.strokeWidth='6px'); }} }}""")
                path = f"out/png/sub_{s['k']}_{pk}.png"
                pg.screenshot(path=path, omit_background=True)
                K.fill_holes(path)
                a = s["start"] + round(s["speech"] * acc / max(1, tot))
                acc += len(ptxt)
                b = s["start"] + (s["frames"] if pk == n - 1 else round(s["speech"] * acc / max(1, tot)))
                subs.append((a, b, K.sub_layer(path)))
        mp4 = "out/_paper.mp4"
        K.render_block(dict(name=NAME, html=html), total, mp4, pg)
        br.close()

    # --- ハコブくん ---
    def full(hh):
        o = hk.Hakobu(mode="full")
        bb = o.body.getbbox()
        o.h = hh; o.w = round((bb[2] - bb[0]) * hh / (bb[3] - bb[1])); o._cache = {}
        return o
    hs, hb = full(HK_SMALL_H), full(HK_BIG_H)
    tline = [(s["start"], s["start"] + s["frames"], s["start"] + s["speech"], s["face"], not s["ei"]) for s in segs]
    tline = [(0, lead, lead, "base", False)] + tline
    mp = pcm.astype(np.float32) / 32768.0
    speak = np.zeros(total, bool); eispk = np.zeros(total, bool)
    for s in segs:
        a0, a1 = s["start"] * SR // FPS, (s["start"] + s["frames"]) * SR // FPS
        if s["ei"]:
            mp[a0:a1] = 0; eispk[s["start"]:s["start"] + s["speech"]] = True
        else:
            speak[s["start"]:s["start"] + s["speech"]] = True
    params = hs.animate(tline, mp)
    for k, a, d, f in FACE:
        ex = hk.EXPR[f]
        for i in range(int(Tf(k, a) * FPS), min(total, int((Tf(k, a) + d) * FPS))):
            eyes, brows, mouth, ring, dy = params[i]
            if not speak[i]:
                mouth = ex["rest"]
            params[i] = (ex["eyes"], ex["brows"], mouth, ring, dy)
    t_end_big, SHR = Tf(0), 0.5
    lean = [(Tf(k, a), hgt, dx) for k, a, hgt, dx in LEAN]

    def intro_pose(t):
        """冒頭の大きなハコブくん → (足もとの中心 x, 足もと y, 角度)"""
        if INTRO == "rise":      # 下から飛び出す
            k = 1.15 * np.sin(min(1, t / 0.45) * np.pi / 2) if t < 0.45 else 1.0 + 0.15 * np.exp(-(t - 0.45) * 6) * np.cos((t - 0.45) * 14)
            return HK_FOOT[0] + 120 + 40 * np.sin(t * 1.1), (FOOT + 90) + (FOOT - (FOOT + 90)) * k, 6 * np.sin(t * 1.3)
        if INTRO == "dash":      # 左から走ってきて、キキッと止まる
            u = min(1.0, t / 0.55)
            x = -400 + (560 + 400) * ease(u)
            rot = -14 * (1 - u) if u < 1 else 10 * np.exp(-(t - 0.55) * 5) * np.sin((t - 0.55) * 16)
            return x, FOOT, rot
        if INTRO == "drop":      # 上から落ちてきて弾む
            if t < 0.4:
                return 540, -100 + 2000 * (t / 0.4) ** 2, 0
            u = t - 0.4
            return 540, FOOT - 180 * abs(np.sin(u * 7)) * np.exp(-u * 3), 5 * np.sin(u * 9) * np.exp(-u * 2)
        if INTRO == "spin":      # くるくる向きを変えながら跳ねる（向きは hk_mirror で）
            ph = (t * 2.4) % 1.0
            return 540, FOOT - 120 * 4 * ph * (1 - ph), 0
        ph = (t * 1.9) % 1.0     # hop: 跳ねて喜ぶ
        by = FOOT - 160 * 4 * ph * (1 - ph)
        if t < 0.3:
            by = (FOOT + 20) + (by - (FOOT + 20)) * ease(t / 0.3)
        return 540 + 30 * np.sin(t * 2.0), by, 8 * np.sin(t * 3.8)

    moves = [(Tf(k, a), kind, d) for k, a, kind, d in MOVES]

    def move_off(t):
        """文の途中の動き → (横, 縦, 角度, 左右反転)"""
        dx = dy = dr = 0.0
        mir = False
        if t < t_end_big and INTRO == "spin":
            mir = int(t * 6) % 2 == 1
        for a, kind, d in moves:
            if not (a <= t < a + d):
                continue
            u = (t - a) / d
            if kind == "hop":
                dy -= 70 * abs(np.sin(np.pi * u * 3))
            elif kind == "shake":
                dx += 18 * np.sin((t - a) * 45) * (1 - u)
            elif kind == "tilt":
                dr += 14 * np.sin(np.pi * u)
            elif kind == "bow":
                dr -= 22 * np.sin(np.pi * u)
            elif kind == "spin":
                mir = int(u * 8) % 2 == 1
                dy -= 40 * abs(np.sin(np.pi * u * 4))
            elif kind == "turn":
                mir = True
            elif kind == "dash":
                dx += 240 * np.sin(np.pi * u)
                dr += 8 * np.sin(np.pi * u)
            elif kind == "back":
                dx -= 130 * np.sin(np.pi * u)
                dr -= 6 * np.sin(np.pi * u)
            elif kind == "jump":
                dy -= 260 * np.sin(np.pi * u)
                dr += 360 * u if d >= 0.8 else 0
        return dx, dy, dr, mir

    def hk_place(t):
        sx = HK_FOOT[0]
        if t < t_end_big + SHR:
            tt = min(t, t_end_big)
            bx, by, rot = intro_pose(tt)
            if t < t_end_big:
                return HK_BIG_H, bx, by, rot
            u = ease((t - t_end_big) / SHR)
            return HK_BIG_H + (HK_SMALL_H - HK_BIG_H) * u, bx + (sx - bx) * u, by + (FOOT - by) * u, rot * (1 - u)
        hh, dx = HK_SMALL_H, 0.0
        for a, h2, d2 in lean:   # 乗り出すときは 0.3 秒で寄って、少し行き過ぎて戻る
            if t >= a:
                u = min(1.0, (t - a) / 0.3)
                k = ease(u) + (0.08 * np.sin(min(1, (t - a - 0.3) / 0.25) * np.pi) if t > a + 0.3 else 0)
                hh, dx = hh + (h2 - hh) * k, dx + (d2 - dx) * k
        rot = 4.0 * np.exp(-(t - lean[0][0]) * 3) * np.sin((t - lean[0][0]) * 12) if lean and t > lean[0][0] else 0.0
        return hh, sx + dx, FOOT, rot

    # --- エイイチ（全身）---
    sprites = {}
    EI_SCALE = max(2, round(1.7 * PPM * getattr(SP, "SIZE", 1.0) / Image.open(f"src/ei/エイイチ_全身_立つ_170px_v2.png").getbbox()[3]))
    for k_, nm in EI_POSES.items():
        im = Image.open(f"src/ei/エイイチ_全身_{nm}_170px_v2.png").convert("RGBA")
        im = im.resize((round(im.width * EI_SCALE), round(im.height * EI_SCALE)), Image.NEAREST)
        pad = 20
        g = Image.new("L", (im.width + 2 * pad, im.height + 2 * pad), 0)
        g.paste(im.split()[3], (pad, pad))
        g = g.filter(ImageFilter.GaussianBlur(7)).point(lambda v: int(min(255, v * 1.6) * 0.45))
        lay = Image.merge("RGBA", (Image.new("L", g.size, 235), Image.new("L", g.size, 232), Image.new("L", g.size, 225), g))
        lay.alpha_composite(im, (pad, pad))
        sprites[k_] = lay.crop((pad, 0, pad + im.width, pad + im.height))
    ei_pcm = pcm.astype(np.float32) / 32768.0
    hop = SR // FPS
    rms = np.array([np.sqrt(np.mean(ei_pcm[i * hop:(i + 1) * hop] ** 2)) if eispk[i] else 0.0 for i in range(total)])
    ref = np.percentile(rms[rms > 0.01], 85) if (rms > 0.01).any() else 1.0
    bob = np.zeros(total, int)
    for i in range(0, total, 3):
        bob[i:i + 3] = -EI_SCALE if rms[i:i + 3].mean() > 0.45 * ref else 0
    poses = sorted((Tf(k, a), p_) for k, a, p_ in POSE)
    t_fall = Tf("end", 0.0)

    t_end = Tf(*ENDING["at"]) if "at" in ENDING else t_fall

    def ei_jump(t):
        if ENDING["kind"] != "jump" or t < t_end:
            return 0
        u = t - t_end
        return int(-120 * abs(np.sin(u * 7.0)) * np.exp(-u * 1.6)) if u < 2.0 else 0

    def fall_deg(t):
        if ENDING["kind"] != "fall":
            return 0.0
        t_fall_ = t_end
        if t < t_fall_:
            return 0.0
        u = (t - t_fall_) / 0.35
        if u < 1:
            return -78 * ease(u)
        return -78 + 6 * np.exp(-(t - t_fall_ - 0.35) * 7) * np.sin((t - t_fall_ - 0.35) * 22)

    # --- 音 ---
    voice = pcm.astype(np.float32) / 32768.0
    ref_v = np.percentile(np.abs(voice[voice != 0]), 90) if (voice != 0).any() else 0.1
    raw = subprocess.run(["ffmpeg", "-v", "error", "-i", "sound/bgm_明るいB_2.mp3", "-ac", "1", "-ar", str(SR), "-f", "s16le", "-"], capture_output=True, check=True).stdout
    bgm = np.frombuffer(raw, np.int16).astype(np.float32) / 32768.0
    bgm = np.tile(bgm, int(np.ceil(len(voice) / len(bgm))) + 1)[:len(voice)]
    bgm *= ref_v / (np.percentile(np.abs(bgm), 90) + 1e-6) * 10 ** (-24 / 20)
    fade = np.ones(len(voice)); nfo = int(1.0 * SR); fade[-nfo:] = np.linspace(1, 0, nfo)
    mix = voice + bgm * fade
    for k, off, name, lv in SE:
        f_ = os.path.join(K.BASE, "sound", name + ".wav")
        if not os.path.exists(f_):
            f_ = os.path.join(os.path.dirname(K.BASE), "_ショート専用", "_se_20261020", name + ".wav")
        raw = subprocess.run(["ffmpeg", "-v", "error", "-i", f_, "-ac", "1", "-ar", str(SR), "-f", "s16le", "-"], capture_output=True, check=True).stdout
        se = np.frombuffer(raw, np.int16).astype(np.float32) / 32768.0
        se *= ref_v / (np.abs(se).max() + 1e-6) * 10 ** (lv / 20)
        s0 = int(Tf(k, off) * SR)
        n = min(len(se), len(mix) - s0)
        if n > 0:
            mix[s0:s0 + n] += se[:n]
    mix *= 0.98 / max(0.98, np.abs(mix).max())
    K.write_wav("out/_mix.wav", (mix * 32767).astype(np.int16))

    # --- 重ねる ---
    out = f"out/{NAME}_版{getattr(SP, 'VER', 1)}.mp4"
    enc = subprocess.Popen(["ffmpeg", "-v", "error", "-y", "-f", "rawvideo", "-pix_fmt", "rgb24", "-s", f"{W}x{H}", "-r", str(FPS), "-i", "-",
                            "-i", "out/_mix.wav", "-map", "0:v", "-map", "1:a", "-c:v", "libx264", "-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p",
                            "-af", "loudnorm=I=-14:TP=-1.5:LRA=11", "-c:a", "aac", "-b:a", "160k", "-ar", "48000", "-movflags", "+faststart",
                            "-frames:v", str(total), out], stdin=subprocess.PIPE)
    dec = subprocess.Popen(["ffmpeg", "-v", "error", "-i", mp4, "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], stdout=subprocess.PIPE)
    for f in range(total):
        t = f / FPS
        bgf = np.frombuffer(dec.stdout.read(W * H * 3), np.uint8).reshape(H, W, 3).copy()
        pose = 1
        for a, p_ in poses:
            if t >= a:
                pose = p_
        spr = sprites[pose]
        ex, ey = int(EI_FOOT[0] - spr.width / 2), EI_FOOT[1] - spr.height + bob[f] + ei_jump(t)
        deg = fall_deg(t)
        if abs(deg) > 0.2:
            img, (ox, oy) = rotate_about(spr, deg, (spr.width / 2, spr.height))
            blend(bgf, np.asarray(img).astype(np.float32), ex + ox, ey + oy)
        else:
            blend(bgf, np.asarray(spr).astype(np.float32), ex, ey)
        size, cx, fy, rot = hk_place(t)
        mdx, mdy, mdr, mir = move_off(t)
        cx, fy, rot = cx + mdx, fy + mdy, rot + mdr
        img = (hb if size > HK_SMALL_H + 60 else hs).frame(params[f])
        if mir:
            img = ImageOps.mirror(img)
        if abs(size - img.height) > 1:
            img = img.resize((round(img.width * size / img.height), round(size)), Image.LANCZOS)
        x, y = int(cx - img.width / 2), int(fy - img.height)
        if abs(rot) > 0.2:
            img, (ox, oy) = rotate_about(img, rot, (img.width / 2, img.height))
            x, y = x + ox, y + oy
        blend(bgf, np.asarray(img).astype(np.float32), x, y)
        for a, b, (sl, sx_, sy_) in subs:
            if a <= f < b:
                blend(bgf, sl, sx_, sy_)
        enc.stdin.write(bgf.tobytes())
    dec.stdout.close(); dec.wait()
    enc.stdin.close(); enc.wait()
    print(f"{out}  {total / FPS:.1f}秒")


if __name__ == "__main__":
    if len(sys.argv) > 2 and sys.argv[1] == "--still":
        build([float(x) for x in sys.argv[2].split(",")])
    else:
        build()
```


## 作業ログ ― モニターの中を、枠線と記号から面に組み直す（版1 → 版2）

### エイイチ「グラフィカルな部分はもう少しクオリティを上げたいかな。雑に見える」

版1のモニターは、細い枠線の札、記号のアイコン（▶■✉◆）、緑と赤の帯、凡例、と要素が多く、1画面で何を見せたいのかがぼやけていました。デザインを直すときの決まり（1画面で伝えることは1つ・細い線より面・色は意味を1つ決めてそこにだけ使う）に照らして、全部の画面を組み直しています。

- ねらい：スマホで見て、モニターの中で何を言っているかが1秒で分かるようにする
- 変わったこと：枠線をやめてベタの面で組んだ。記号のアイコンをやめて番号（01〜07）にした。どの画面も左上に「番号 / 07・名前」、その下に言いたいことを1文、図は1つ。オレンジは止まり・いまの手順・選んだ1つ・✕ だけにし、緑と赤をやめた
- 見込める効果：見て分かるかは、X の動画の再生と、見られた長さで確かめる（まだ出していないので見込み）

【画像】LOG455_03_monitor_v1_v2.png 同じ5.2秒目のコマ。左の版1は細い枠線と記号のアイコン、右の版2はベタの面で、左に大きな7、右に7行の一覧

### 字幕は1行ずつにして、足もとの下に置いた

2人を寄せると、2行の字幕の箱がハコブくんの体を半分隠しました。字幕を1行ずつ（48px・1行15字まで）にし、箱を足もとの下（y 1590）に置いています。YouTube のショートでは y 1640 より下にタイトルが重なるので、箱の下の端が少しかかりますが、字は1640より上に収まっています。


## 作業ログ ― 床の円をゆっくり回し、紙吹雪を細かくする（版3）

### エイイチ「紙吹雪をもっと細かく華やかに。床のサークルをゆっくり回して」

床の円は Unity の背景に焼き込まれているので、そのままでは回せません。Unity で撮り直す代わりに、背景から円を消し、同じカメラの値で円を描き直して回すことにしました。

1. stage_meta.json のカメラの値（位置 (0, 3.273, -6) m・俯角12度・縦の画角55度）で、床の上の半径1.37〜1.63m の帯を画面に写し、その帯を OpenCV の inpaint（まわりの色で穴を埋める処理）で埋める
2. 破線の明るさを、元の画像から10度ごとに拾ってならす。手前は明るく、奥は暗い、という Unity の照明を残すため
3. 書き出しのたびに、ページの canvas に36本の破線を、48秒で1周する速さで描く

写す計算が合っているかは、円の中心が y 1510、左の足もとが (291.7, 1675.6) に写ることで確かめました（stage_meta.json の値と同じ）。

【画像】LOG455_02_floor_ring.png 左から、Unity で撮った円、円を消した床、描き直して回している版3のコマ

### make_noring.py（全文）

会話の中で2回に分けて動かしたもの（inpaint と明るさの表）を、記事に載せるために1つのファイルにまとめました。まとめたファイルを同じ舞台の画像で動かし直し、できた画像（画素の差の最大が0）と明るさの表が、動画に使ったものと同じになることを確かめています。

```python
# -*- coding: utf-8 -*-
"""舞台（Unity で撮った stage_bg.png）から床の円を消し、回して描き直すための値を作る（2026-11-06「研究のスキル一覧」版3）。
エイイチ「床のサークルをゆっくり回して」。Unity を撮り直さず、円を消した床の上に butai_parts.stage_fx が円を描き直す。

1. stage_meta.json のカメラの値（位置・俯角・縦の画角）で、床の上の半径 1.37〜1.63m の帯を画面に写し、その帯を OpenCV の inpaint で埋める
   → stage_bg_noring.png / stage_bg_off_noring.png
2. 円の破線の明るさを、元の画像から 10度ごとに拾ってならす（手前は明るく、奥は暗い。Unity の照明のまま）
   → ring_lut.json（lut: 36個の RGB・fy・カメラの位置・俯角）

使い方: python3 make_noring.py <舞台のフォルダ>（stage_bg.png・stage_bg_off.png・stage_meta.json があるところ）
会話の中で2回に分けて動かしたもの（inpaint と明るさの表）を、1つのファイルにまとめた。
"""
import json, math, os, sys
import numpy as np
import cv2
from PIL import Image

D = sys.argv[1] if len(sys.argv) > 1 else "stage"
M = json.load(open(os.path.join(D, "stage_meta.json")))
c = M["camera"]
C = np.array(c["position_m"])
p = math.radians(c["pitch_down_deg"])
f = np.array([0, -math.sin(p), math.cos(p)])      # カメラの前
u = np.array([0, math.cos(p), math.sin(p)])       # カメラの上
fy = 960 / math.tan(math.radians(c["vertical_fov_deg"] / 2))


def proj(x, z):
    """床（y=0）の点 (x, z) を画面の px に写す。円の中心が原点"""
    d = np.array([x, 0, z]) - C
    return 540 + fy * d[0] / (d @ f), 960 - fy * (d @ u) / (d @ f)


# 確かめ: 円の中心は y 1510、左の足もとは (291.7, 1675.6) に写る（stage_meta.json の値と同じ）
print(proj(0, 0), proj(-0.75, -1))

# 1. 円を消す
th = np.linspace(0, 2 * np.pi, 720)
outer = np.array([proj(1.63 * np.cos(a), 1.63 * np.sin(a)) for a in th], np.int32)
inner = np.array([proj(1.37 * np.cos(a), 1.37 * np.sin(a)) for a in th], np.int32)
mask = np.zeros((1920, 1080), np.uint8)
cv2.fillPoly(mask, [outer], 255)
cv2.fillPoly(mask, [inner], 0)
for nm in ("stage_bg", "stage_bg_off"):
    im = cv2.imread(os.path.join(D, f"{nm}.png"))
    out = cv2.inpaint(im, mask, 9, cv2.INPAINT_TELEA)
    cv2.imwrite(os.path.join(D, f"{nm}_noring.png"), out)

# 2. 破線の明るさ（10度ごとに、いちばん明るい点を拾い、前後とならす）
im = np.asarray(Image.open(os.path.join(D, "stage_bg.png")).convert("RGB")).astype(float)
lut = []
for k in range(36):
    best = np.zeros(3)
    for a in np.linspace(math.radians(k * 10), math.radians(k * 10 + 10), 30):
        for rr in (1.47, 1.5, 1.53):
            x, y = proj(rr * math.cos(a), rr * math.sin(a))
            px = im[int(y), int(x)]
            if px.sum() > best.sum():
                best = px
    lut.append(best)
lut = np.array(lut)
sm = (np.roll(lut, 1, 0) + 2 * lut + np.roll(lut, -1, 0)) / 4
print(sm.astype(int)[::3].tolist())
json.dump(dict(lut=sm.round().astype(int).tolist(), fy=fy, C=list(C), pitch=c["pitch_down_deg"]),
          open(os.path.join(D, "ring_lut.json"), "w"))
```

```text
(np.float64(540.0), np.float64(1510.1998084280149)) (np.float64(291.7412860969673), np.float64(1675.6201892029471))
[[62, 41, 5], [70, 43, 5], [129, 71, 9], [154, 82, 14], [128, 68, 7], [121, 65, 8], [107, 59, 9], [71, 44, 6], [65, 42, 6], [65, 43, 6], [90, 54, 9], [115, 66, 10]]
```

紙吹雪は、CSS の四角60片から、canvas に描く230片に変えました。円の左右の2か所から打ち上げ、1片を小さくし（5〜11px）、四角・丸・細長いリボン・光る星の4種類が、裏返りながら揺れて落ちます。エイイチの返事は「OK。」でした。


## 作業ログ ― 部品 butai_parts.py と、紙吹雪のほかの演出7つ

### 2本目からは spec.py に場面と動きだけを書く

エイイチの「Claude や Claude Code ネタで作って欲しい」で4本、続けて「さらに5本」で5本を作りました。1本目の spec.py にあった、モニターの見た目（CSS）・場面の出し入れ（win）・小さな見出し（kick）・床の円と紙吹雪（stage_fx）を butai_parts.py に移し、各回の spec.py は `from butai_parts import *` で始めて、場面と動きと効果音だけを書く形にしています。

### エイイチ「毎回紙吹雪なので違うパターンも考えてください」

4本を出したあと、山場がどれも紙吹雪だと指摘を受けました。stage_fx に、紙吹雪のほかの演出を7つ足し、話の中身に合わせて選ぶようにしています。

| 演出 | 動き | 合う場面 | 使った回 |
|---|---|---|---|
| 紙吹雪 | 円の左右から打ち上げて、ひらひら落ちる | お祝い | 研究のスキル一覧・このショートの作り方 |
| rays | 床の円の真ん中から光の筋が回る | 決まった所・浮いた時間 | 記事の公開は誰が・全部ログに残す |
| speed | マンガの集中線がモニターへ集まる | 叩きつけ・ツッコミ | Claudeに覚えてもらう・Claude Codeへの頼み方 |
| shock | 床の円から波紋が3つ広がる | 知らせ・音・決定 | Claudeに覚えてもらう・Claude Codeへの頼み方・寝ている間に回す・並行で作る |
| spot | まわりを暗くして、1人にスポットライト | 決めるのは○○ | 別のAIに疑わせる |
| stars | 上の方に星がまたたき、少し暗くなる | 夜 | 寝ている間に回す |
| shine | モニターの上を光の帯が斜めに走る | 作り直した | Claudeのデザイン |
| rise | 床から小さな四角が立ちのぼる | データ・記録 | 全部ログに残す |

暗くする演出（spot・stars）と光の筋は、モニターの中にはかけません。モニターに出している言葉が読めなくなるためです。

【画像】LOG455_04_effects.png 紙吹雪と7つの演出を、それぞれ使った回のコマで並べた

【図解】LOG455_stage_layers.html

### butai_parts.py（全文）

```python
# -*- coding: utf-8 -*-
"""舞台型ショートの部品（2026-11-06「研究のスキル一覧」で決めた見た目を、2本目から共通にした）。
モニターの中（900×600）は面で組む: 左上に小さな見出し、その下に言いたいことを1文、図は1つ。
オレンジは「いま話している1つ」だけ。細い枠線・記号のアイコンは使わない。
"""
import json, os
from kabe_kit import shake_js, counter_js

ORANGE, FACE_C, FACE_2, DIM, INK = "#E2683A", "#1B2433", "#2C3647", "#8A93A3", "#F5F3EE"
HERE = os.path.dirname(os.path.abspath(__file__))
STAGE_DIR = os.path.join(HERE, "stage")

CSS = f"""<style>
.win {{ position:absolute; inset:0; opacity:0; }}
.kick {{ position:absolute; left:48px; top:40px; font-size:26px; font-weight:800; color:{DIM}; letter-spacing:.04em; }}
.kick b {{ display:inline-block; width:14px; height:14px; background:{ORANGE}; border-radius:3px; margin-right:12px; vertical-align:2px; }}
.msg {{ position:absolute; left:48px; right:48px; top:96px; font-size:58px; font-weight:900; line-height:1.22; color:{INK}; }}
.card {{ position:absolute; border-radius:12px; background:{FACE_C}; color:{INK}; font-weight:900; display:flex; flex-direction:column; justify-content:center; padding:0 26px; }}
.card small {{ font-size:22px; color:{DIM}; font-weight:800; margin-bottom:4px; }}
.card.or {{ background:{ORANGE}; color:#fff; }} .card.or small {{ color:#ffe1d4; }}
.line {{ position:absolute; height:16px; border-radius:8px; background:{FACE_2}; }}
.hero {{ position:absolute; font-weight:900; color:{ORANGE}; line-height:.9; letter-spacing:-.03em; }}
.hero span {{ font-size:.32em; color:{INK}; letter-spacing:0; margin-left:8px; }}
.arrowR {{ position:absolute; width:0; height:0; border-top:16px solid transparent; border-bottom:16px solid transparent; border-left:22px solid {FACE_2}; }}
.chk {{ position:absolute; left:48px; right:48px; height:76px; border-radius:10px; background:{FACE_C}; display:flex; align-items:center; gap:22px; padding:0 22px; font-size:36px; font-weight:800; color:{INK}; opacity:0; }}
.chk i {{ font-style:normal; width:48px; height:48px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:30px; font-weight:900; background:#3A4558; color:{INK}; }}
.chk.ng i {{ background:{ORANGE}; }} .chk.ng {{ color:{ORANGE}; }}
.bubble {{ position:absolute; left:48px; padding:16px 26px; border-radius:18px 18px 18px 4px; background:{FACE_C}; font-size:32px; font-weight:800; color:{INK}; opacity:0; }}
.strike {{ position:absolute; height:10px; border-radius:5px; background:{ORANGE}; transform-origin:left; transform:scaleX(0); }}
.stamp {{ position:absolute; background:{ORANGE}; color:#fff; font-weight:900; padding:6px 28px; border-radius:10px; opacity:0; white-space:nowrap; }}
.qm {{ position:absolute; left:0; right:0; top:60px; text-align:center; font-size:400px; font-weight:900; line-height:1; color:{ORANGE}; }}
@keyframes show {{ to {{ opacity:1; }} }}
@keyframes hide {{ to {{ opacity:0; }} }}
@keyframes up {{ from {{ opacity:0; transform:translateY(24px); }} to {{ opacity:1; transform:none; }} }}
@keyframes inR {{ from {{ opacity:0; transform:translateX(40px); }} to {{ opacity:1; transform:none; }} }}
@keyframes on {{ to {{ background:{ORANGE}; color:#fff; }} }}
@keyframes dim {{ to {{ opacity:.22; }} }}
@keyframes blurOut {{ to {{ opacity:0; filter:blur(14px); transform:translateY(-30px); }} }}
@keyframes strike {{ to {{ transform:scaleX(1); }} }}
@keyframes slam {{ 0% {{ opacity:0; transform:rotate(-6deg) scale(2.2); }} 80% {{ opacity:1; transform:rotate(-6deg) scale(.96); }} 100% {{ opacity:1; transform:rotate(-6deg) scale(1); }} }}
@keyframes pop {{ 0% {{ opacity:0; transform:scale(.6); }} 70% {{ opacity:1; transform:scale(1.04); }} 100% {{ opacity:1; transform:none; }} }}
@keyframes breathe {{ 0%,100% {{ transform:scale(1); }} 50% {{ transform:scale(1.05); }} }}
@keyframes grow {{ from {{ transform:scaleX(0); }} to {{ transform:scaleX(1); }} }}
</style>"""


def win(t0, t1, inner):
    an = f"show .15s linear {t0:.3f}s forwards" + (f", hide .15s linear {t1:.3f}s forwards" if t1 else "")
    return f'<div class="win" style="animation:{an}">{inner}</div>'


def kick(name):
    return f'<div class="kick"><b></b>{name}</div>'


def a(name, t, d=.3, ease="ease-out"):
    """animation の1つ分"""
    return f"{name} {d}s {ease} {t:.3f}s forwards"


def stage_fx(T, bursts=(), shakes=(), ring_sec=48.0, fx=()):
    """床の円を回す（stage_bg_noring.png の上にカメラの値で描き直す）＋紙吹雪（bursts: [(秒, 片の数)]）＋画面の揺れ
    fx: 紙吹雪のほかの演出 [dict(kind=, t=秒, d=長さ, ...)]（毎回紙吹雪にしない・2026-11-06 エイイチの指示）
      rays  … 床の円の真ん中から光の筋が回る（お祝い・決まった所）
      speed … マンガの集中線がモニターに集まる（叩きつけ・ツッコミ）
      shock … 床の円から波紋が広がる（知らせ・音・決定）
      spot  … まわりを暗くして、x の位置にスポットライト（決めるのは○○）
      stars … 上の方に星がまたたき、少し暗くなる（夜・ひらめき）
      shine … モニターの上を光の帯が斜めに走る（作り直した・きれいになった）
      rise  … 床から小さな四角が立ちのぼる（データ・記録・読まれる）"""
    R = json.load(open(os.path.join(STAGE_DIR, "ring_lut.json")))
    bs = [dict(t=t, n=n) for t, n in bursts]
    js = f"""<canvas id="fx" width="1080" height="1920" style="position:absolute;left:0;top:0;z-index:3;pointer-events:none"></canvas>
<script>
(function(){{
const LUT={json.dumps(R["lut"])}, FY={R["fy"]:.4f}, C={json.dumps(R["C"])}, PIT={R["pitch"]}*Math.PI/180, SEC={ring_sec};
const f1=-Math.sin(PIT), f2=Math.cos(PIT), u1=Math.cos(PIT), u2=Math.sin(PIT);
function P(x,z){{ const dx=x-C[0], dy=-C[1], dz=z-C[2]; const zc=dy*f1+dz*f2, yc=dy*u1+dz*u2; return [540+FY*dx/zc, 960-FY*yc/zc]; }}
function rnd(seed){{ return function(){{ seed|=0; seed=seed+0x6D2B79F5|0; let t=Math.imul(seed^seed>>>15,1|seed); t=t+Math.imul(t^t>>>7,61|t)^t; return ((t^t>>>14)>>>0)/4294967296; }}; }}
const COLS=["#E2683A","#F2C14E","#F5F3EE","#57B5F8","#FFB38A","#F2C14E","#E2683A"];
const BURSTS={json.dumps(bs)};
const FX={json.dumps(list(fx))};
const parts=[];
BURSTS.forEach((b,bi)=>{{ const r=rnd(7+bi*101);
  for(let i=0;i<b.n;i++){{ const side=i%2?1:-1; const x0=540+side*400, y0=1470;
    const ang=(-90+side*-28+(r()-.5)*44)*Math.PI/180, sp=1300+r()*1300;
    parts.push({{t0:b.t+r()*0.18, x0, y0, vx:Math.cos(ang)*sp, vy:Math.sin(ang)*sp, k:2.6+r()*1.4, g:900+r()*300,
      sh:(r()<.12?3:(r()<.25?1:(r()<.45?2:0))), w:5+r()*6, c:COLS[Math.floor(r()*COLS.length)], rs:(r()-.5)*14, r0:r()*6.28,
      fl:6+r()*10, ph:r()*6.28, sw:16+r()*26, life:2.6+r()*1.4}}); }} }});
const cv=document.getElementById('fx'), g=cv.getContext('2d');
function ring(t){{ const N=36, rot=t*2*Math.PI/SEC;
  for(let k=0;k<N;k++){{ const a0=rot+k*2*Math.PI/N, a1=a0+Math.PI/N, am=(a0+a1)/2;
    let idx=Math.floor((((am%(2*Math.PI))+2*Math.PI)%(2*Math.PI))/(2*Math.PI)*36)%36; const c=LUT[idx];
    const pts=[]; for(let j=0;j<=6;j++){{ const a=a0+(a1-a0)*j/6; pts.push(P(1.55*Math.cos(a),1.55*Math.sin(a))); }}
    for(let j=6;j>=0;j--){{ const a=a0+(a1-a0)*j/6; pts.push(P(1.45*Math.cos(a),1.45*Math.sin(a))); }}
    g.beginPath(); g.moveTo(pts[0][0],pts[0][1]); pts.slice(1).forEach(q=>g.lineTo(q[0],q[1])); g.closePath();
    g.fillStyle=`rgb(${{Math.min(255,c[0]*1.15)}},${{Math.min(255,c[1]*1.15)}},${{Math.min(255,c[2]*1.15)}})`; g.fill(); }} }}
function confetti(t){{ for(const p of parts){{ const u=t-p.t0; if(u<0||u>p.life) continue;
    const e=(1-Math.exp(-p.k*u))/p.k; let x=p.x0+p.vx*e, y=p.y0+(p.vy+p.g/p.k)*e-p.g*u/p.k;
    x+=p.sw*Math.sin(p.fl*0.5*u+p.ph)*Math.min(1,u/0.6);
    const al=u>p.life-0.6?(p.life-u)/0.6:1; g.save(); g.globalAlpha=al; g.translate(x,y); g.rotate(p.r0+p.rs*u);
    const fs=Math.max(.15,Math.abs(Math.cos(p.fl*u+p.ph)));
    if(p.sh===3){{ const tw=0.5+0.5*Math.sin(u*18+p.ph); g.fillStyle="#FFF7E0"; g.globalAlpha=al*tw; const s=p.w*1.3;
      g.beginPath(); g.moveTo(0,-s); g.lineTo(s*.25,-s*.25); g.lineTo(s,0); g.lineTo(s*.25,s*.25); g.lineTo(0,s); g.lineTo(-s*.25,s*.25); g.lineTo(-s,0); g.lineTo(-s*.25,-s*.25); g.closePath(); g.fill(); }}
    else {{ g.scale(1,fs); g.fillStyle=p.c;
      if(p.sh===1){{ g.beginPath(); g.arc(0,0,p.w*.55,0,6.283); g.fill(); }}
      else if(p.sh===2){{ g.fillRect(-p.w*.22,-p.w*1.6,p.w*.44,p.w*3.2); }}
      else {{ g.fillRect(-p.w/2,-p.w*.8,p.w,p.w*1.6); }} }}
    g.restore(); }} }}

const SCR=[90,470,990,1070];
function outClip(){{ g.beginPath(); g.rect(0,0,1080,1920); g.rect(SCR[0],SCR[1],SCR[2]-SCR[0],SCR[3]-SCR[1]); g.clip('evenodd'); }}
function env(u,d){{ if(u<0||u>d) return 0; return Math.min(1,u/0.25)*Math.min(1,(d-u)/0.5); }}
function fxRays(u,e){{ const al=env(u,e.d)*0.35, cx=540, cy=1500, n=14, rot=u*0.6;
  g.save(); outClip(); g.globalAlpha=al; for(let k=0;k<n;k++){{ const a0=rot+k*2*Math.PI/n, a1=a0+Math.PI/n*0.55;
    const gr=g.createRadialGradient(cx,cy,0,cx,cy,1500); gr.addColorStop(0,'rgba(255,214,140,1)'); gr.addColorStop(1,'rgba(255,214,140,0)');
    g.fillStyle=gr; g.beginPath(); g.moveTo(cx,cy); g.arc(cx,cy,1500,a0,a1); g.closePath(); g.fill(); }} g.restore(); }}
function fxSpeed(u,e){{ if(u<0||u>e.d) return; const r=rnd(Math.floor(u*20)+11), cx=540, cy=770; g.save(); g.globalAlpha=Math.min(1,(e.d-u)/0.3)*0.85; g.fillStyle='#fff';
  for(let k=0;k<70;k++){{ const a=r()*6.283, w=0.004+r()*0.01, r0=420+r()*160, r1=1300;
    g.beginPath(); g.moveTo(cx+Math.cos(a-w)*r1, cy+Math.sin(a-w)*r1); g.lineTo(cx+Math.cos(a)*r0, cy+Math.sin(a)*r0); g.lineTo(cx+Math.cos(a+w)*r1, cy+Math.sin(a+w)*r1); g.closePath(); g.fill(); }}
  g.restore(); }}
function fxShock(u,e){{ for(let w=0;w<3;w++){{ const v=u-w*0.35; if(v<0||v>1.4) continue; const rr=0.3+v*2.4, al=(1-v/1.4)*0.9;
    g.save(); g.globalAlpha=al; g.strokeStyle=e.c||'#E2683A'; g.lineWidth=10*(1-v/1.4)+2; g.beginPath();
    for(let j=0;j<=72;j++){{ const a=j/72*6.283, q=P(rr*Math.cos(a), rr*Math.sin(a)); j?g.lineTo(q[0],q[1]):g.moveTo(q[0],q[1]); }} g.stroke(); g.restore(); }} }}
function fxSpot(u,e){{ const al=env(u,e.d); if(!al) return; g.save(); outClip(); g.globalAlpha=al*0.78; g.fillStyle='#000';
  g.beginPath(); g.rect(0,0,1080,1920); g.ellipse(e.x, 1500, 230, 70, 0, 0, 6.283); g.fill('evenodd');
  g.globalAlpha=al*0.22; const gr=g.createLinearGradient(0,600,0,1520); gr.addColorStop(0,'rgba(255,240,210,0)'); gr.addColorStop(1,'rgba(255,240,210,1)');
  g.fillStyle=gr; g.beginPath(); g.moveTo(e.x-60,560); g.lineTo(e.x+60,560); g.lineTo(e.x+230,1500); g.lineTo(e.x-230,1500); g.closePath(); g.fill(); g.restore(); }}
function star(x,y,s){{ g.beginPath(); g.moveTo(x,y-s); g.lineTo(x+s*.22,y-s*.22); g.lineTo(x+s,y); g.lineTo(x+s*.22,y+s*.22); g.lineTo(x,y+s); g.lineTo(x-s*.22,y+s*.22); g.lineTo(x-s,y); g.lineTo(x-s*.22,y-s*.22); g.closePath(); g.fill(); }}
function fxStars(u,e){{ const al=env(u,e.d); if(!al) return; g.save(); outClip(); g.globalAlpha=al*0.45; g.fillStyle='#05070d'; g.fillRect(0,0,1080,1920);
  const r=rnd(5); g.fillStyle='#FFF4D6'; for(let k=0;k<46;k++){{ let x=r()*1080, y=r()*1900; if(x>70&&x<1010&&y>450&&y<1090){{ y=y<770?450-r()*300:1090+r()*120; }}
    const ph=r()*6.283, sp=2+r()*4, s=4+r()*10; g.globalAlpha=al*(0.35+0.65*Math.max(0,Math.sin(u*sp+ph))); star(x,y,s); }} g.restore(); }}
function fxShine(u,e){{ if(u<0||u>e.d) return; const v=u/e.d, x=SCR[0]-300+(SCR[2]-SCR[0]+600)*v; g.save(); g.beginPath(); g.rect(SCR[0],SCR[1],SCR[2]-SCR[0],SCR[3]-SCR[1]); g.clip();
  const gr=g.createLinearGradient(x-120,0,x+120,0); gr.addColorStop(0,'rgba(255,255,255,0)'); gr.addColorStop(.5,'rgba(255,255,255,.55)'); gr.addColorStop(1,'rgba(255,255,255,0)');
  g.fillStyle=gr; g.translate(x,770); g.rotate(0.35); g.fillRect(-120,-900,240,1800); g.restore(); }}
function fxRise(u,e){{ const r=rnd(9); const cols=['#E2683A','#57B5F8','#F5F3EE']; for(let k=0;k<70;k++){{ const t0=r()*e.d*0.7, life=1.6+r()*1.2, v=u-t0; if(v<0||v>life) continue;
    const x0=200+r()*680, y=1560-v*(380+r()*260), x=x0+Math.sin(v*3+k)*14, s=6+r()*8; g.save(); g.globalAlpha=Math.min(1,v/0.2)*Math.min(1,(life-v)/0.5)*0.9; g.fillStyle=cols[k%3]; g.fillRect(x,y,s,s); g.restore(); }} }}
const FXF={{rays:fxRays,speed:fxSpeed,shock:fxShock,spot:fxSpot,stars:fxStars,shine:fxShine,rise:fxRise}};
function effects(t,front){{ for(const e of FX){{ const back=(e.kind==='rays'||e.kind==='shock'||e.kind==='rise'); if(back===front) continue; FXF[e.kind](t-e.t,e); }} }}
const prev=window.hook;
window.hook=function(t){{ if(prev) prev(t); g.clearRect(0,0,1080,1920); effects(t,false); ring(t); effects(t,true); confetti(t); }};
window.hookBusy=function(t){{ return true; }};
window.hook(0);
}})();
</script>"""
    return js + (shake_js(list(shakes), amp=7) if shakes else "")


# 舞台型の共通の値（spec.py で from butai_parts import * して使う）
STAGE = STAGE_DIR
BG_NAME = "stage_bg_noring"
FOOT_L, FOOT_R, SIZE, SUB_MID = (390, 1525), (690, 1525), 0.88, 1590
SUB_ONE_LINE = True
LEAD = 0.8

OUTRO_TEXT = "研究の記録は、毎日サイトで公開中です！"


def outro(T, k):
    """締め（サイトへの入口）。モニターを pixelstomotors.com で覆う。文 k は narration.py の最後に足した OUTRO_TEXT の文
    （2026-11-07 エイイチ「うちのサイトへの動線部分がないけど？」。2本目からの8本で入れ忘れていた）"""
    t = T(k, 0)
    return (f'<div class="win" style="z-index:10;background:#0E1420;animation:show .2s linear {t:.3f}s forwards">'
            f'<div class="kick"><b></b>研究の記録</div>'
            f'<div style="position:absolute;left:0;right:0;top:210px;text-align:center;font-size:76px;font-weight:900;color:{INK};opacity:0;animation:pop .4s ease-out {t + 0.25:.3f}s forwards">pixelstomotors<span style="color:{ORANGE}">.com</span></div>'
            f'<div style="position:absolute;left:0;right:0;top:330px;text-align:center;font-size:38px;font-weight:800;color:{DIM};opacity:0;animation:up .4s ease-out {t + 0.9:.3f}s forwards">毎日、公開中</div></div>')
```


## 作業ログ ― 10本の台本と画面（narration.py と spec.py）

エイイチのセリフは、どれもエイイチがこれまでに話した言葉（チャットで話したこと・MASTER・動画の決まり）から取り、どこから取ったかを narration.py の1行目に書いています。ハコブくんのセリフは、決まりや記録に書いてある事実だけにしました。

| 回 | 長さ | エイイチのセリフのもと |
|---|---|---|
| 研究のスキル一覧（版5） | 29.3秒 | 「今まで煩わしかったターミナルでの作業から解放されたこと」（この会話） |
| Claude Codeへの頼み方（版4） | 26.9秒 | Claude Code への指示を自分で貼って渡す形はNG（2026-11-03） |
| 寝ている間に回す（版3） | 24.9秒 | 寝ている間も依頼を続けて回せるように／途中経過は毎時間は多い（2026-10-27・11-05） |
| Claudeに覚えてもらう（版4） | 24.0秒 | 「いい加減覚えてください」（2026-11-01・Log 番号の件） |
| 記事の公開は誰が（版4） | 27.9秒 | 記事1本の公開に30〜45分／Studio の更新はもう面倒でやりたくない |
| 別のAIに疑わせる（版4） | 25.7秒 | codex に任せるとただのニュースのようになる（2026-10-28）／最終判断はエイイチ（MASTER） |
| 並行で作る（版3） | 23.4秒 | 何本も並行して作るので一覧で見たい（2026-10-15）／会議中に喋られると困る |
| Claudeのデザイン（版3） | 24.8秒 | 弱いのはデザイン力ではなく進め方と統一する力（2026-10-24） |
| 全部ログに残す（版2） | 21.9秒 | 最初はブログ記事のつもり／全部残せば AI に読ませて使える／AI にしっかり読まれている |
| このショートの作り方（版3） | 23.0秒 | いちばん時間がかかるのは音声確認（2026-11-02） |

声は、ハコブくんがいつもの声、エイイチは槙原さんの声（ElevenLabs の自作の声を流用）で、Mac から tts_eleven.py で作りました。文ごとの長さ（前後の無音を切ったあと）は次のとおりです。

```text
研究のスキル一覧	2.56 / 2.48 / 3.55 / 4.33 / 2.99 / 2.14 / 5.60 / 3.27
Claude Codeへの頼み方	2.93 / 2.72 / 4.08 / 4.95 / 2.60 / 3.76 / 3.27
寝ている間に回す	2.34 / 2.33 / 3.59 / 3.86 / 3.87 / 3.13 / 3.27
Claudeに覚えてもらう	2.72 / 2.91 / 3.37 / 3.40 / 1.85 / 3.76 / 3.27
記事の公開は誰が	3.12 / 5.28 / 3.98 / 3.91 / 3.30 / 2.42 / 3.27
別のAIに疑わせる	3.00 / 2.88 / 3.51 / 3.66 / 4.84 / 2.01 / 3.27
並行で作る	3.64 / 3.01 / 2.56 / 2.94 / 2.09 / 3.23 / 3.27
Claudeのデザイン	2.38 / 2.27 / 4.63 / 3.42 / 3.40 / 2.83 / 3.27
全部ログに残す	2.83 / 2.59 / 3.05 / 3.47 / 3.45 / 4.50
このショートの作り方	2.11 / 3.32 / 3.52 / 3.56 / 2.83 / 1.79 / 3.27
```

### 01 研究のスキル一覧（29.3秒）の narration.py

```python
# -*- coding: utf-8 -*-
# 研究のスキル一覧（ショート専用・舞台型の試作1本目・2026-11-06）。#6 はエイイチの言葉「今まで煩わしかったターミナルでの作業から解放されたこと」から
FACES = ("base", "smile", "surprise", "doubt", "think", "trouble", "joy", "wink")
SEGS = [
 ("ask_ei", "base", "ロボットの研究、ひとりで回せる？", 0.15),
 ("seven", "joy", "回せます。仕組みは、7つ！", 0.15),
 ("ops", "base", "1つは、ロボットの動画から、止まった所を読む。", 0.1),
 ("gpu", "base", "1つは、GPUを借りて学習。終わったら、消す。", 0.1),
 ("doubt", "wink", "1つは、別のAIに、疑わせる。", 0.2),
 ("fav_ei", "base", "で、いちばん助かってるのは…", 0.1),
 ("inbox_ei", "base", "依頼箱。今まで煩わしかった、ターミナルの作業から、解放された！", 0.3),
 ("end", "smile", "研究の記録は、毎日サイトで公開中です！", 0.5),
]
ELEVEN_READ = [("GPU", "ジーピーユー"), ("AI", "エーアイ"), ("7つ", "ななつ"), ("1つ", "ひとつ"), ("依頼箱", "いらいばこ"), ("…", "。")]
EI_LINES = [i for i, seg in enumerate(SEGS) if seg[0].endswith("_ei")]
READ = [("「", ""), ("」", "")]


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

### 01 研究のスキル一覧 の spec.py

```python
# -*- coding: utf-8 -*-
"""研究のスキル一覧（ショート専用・舞台型の試作1本目・2026-11-06）
奥のモニターに1つずつ出す。中身はエイイチが実際に使っている仕組みだけ（誇張しない）。
#6 のセリフはエイイチの言葉「今まで煩わしかったターミナルでの作業から解放されたこと」から。
"""
import json, os
from kabe_kit import shake_js

NAME, VER = "研究のスキル一覧", 5
HEAD = "ひとりで<br>ロボット研究？"
LEAD = 0.8
STAGE = "stage"
BG_NAME = "stage_bg_noring"   # 床の円を消した舞台（円は OVER で回しながら描く）
T_ON = (0, 1.3)
FOOT_L, FOOT_R, SIZE, SUB_MID = (390, 1525), (690, 1525), 0.88, 1590
SUB_ONE_LINE = True   # 字幕は1行ずつにして、箱を足もとの下に収める   # 円の奥寄りに、2人を寄せて立たせる（版2: エイイチ「距離感もっと近くて良さそう」）

ORANGE = "#E2683A"       # 暗い画面の上で読めるよう、#C4471F を明るくした。色の意味は「いま話している1つ」だけ
FACE_C, DIM, INK = "#1B2433", "#8A93A3", "#F5F3EE"
ITEMS = ["運用分析", "GPUで学習", "依頼箱", "課題地図", "事実確認", "声の単語帳", "記録→記事"]

# 画面の決まり（900×600）: 左上に「番号 / 07」と名前、真ん中に言いたいことを1つ、図は面（ベタ）で。線・枠・記号のアイコンは使わない
CSS = f"""<style>
.win {{ position:absolute; inset:0; opacity:0; }}
.no {{ position:absolute; left:48px; top:40px; display:flex; align-items:baseline; gap:14px; font-weight:900; }}
.no b {{ font-size:40px; color:{ORANGE}; letter-spacing:.02em; }}
.no small {{ font-size:22px; color:{DIM}; }}
.no span {{ font-size:26px; color:{DIM}; font-weight:700; margin-left:10px; }}
.msg {{ position:absolute; left:48px; right:48px; top:110px; font-size:62px; font-weight:900; line-height:1.2; color:{INK}; }}
.rows {{ position:absolute; right:48px; top:46px; width:430px; display:flex; flex-direction:column; gap:10px; }}
.row {{ height:64px; background:{FACE_C}; border-radius:10px; display:flex; align-items:center; padding:0 22px; gap:18px; font-size:30px; font-weight:800; color:{INK}; opacity:0; }}
.row em {{ font-style:normal; font-size:22px; font-weight:900; color:{DIM}; width:30px; }}
.seven {{ position:absolute; left:48px; top:120px; opacity:0; }}
.seven b {{ display:block; font-size:300px; font-weight:900; color:{ORANGE}; line-height:.9; letter-spacing:-.04em; }}
.seven span {{ display:block; font-size:44px; font-weight:900; color:{INK}; margin-top:48px; }}
.track {{ position:absolute; left:48px; right:48px; top:330px; height:84px; display:flex; gap:6px; }}
.track i {{ display:block; height:100%; border-radius:8px; background:#2C3647; }}
.track i.st {{ background:#2C3647; }}
.head {{ position:absolute; top:312px; width:8px; height:120px; border-radius:4px; background:{INK}; left:48px; }}
.tag {{ position:absolute; top:440px; width:140px; text-align:center; font-size:28px; font-weight:900; color:{ORANGE}; opacity:0; }}
.steps {{ position:absolute; left:48px; right:48px; top:300px; display:flex; gap:16px; }}
.step {{ flex:1; height:190px; border-radius:12px; background:{FACE_C}; display:flex; flex-direction:column; justify-content:center; padding:0 28px; color:{DIM}; }}
.step em {{ font-style:normal; font-size:24px; font-weight:900; }}
.step b {{ font-size:54px; font-weight:900; margin-top:6px; }}
.chk {{ position:absolute; left:48px; right:48px; height:76px; border-radius:10px; background:{FACE_C}; display:flex; align-items:center; gap:22px; padding:0 22px;
        font-size:36px; font-weight:800; color:{INK}; opacity:0; }}
.chk i {{ font-style:normal; width:48px; height:48px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:30px; font-weight:900; background:#3A4558; color:{INK}; }}
.chk.ng i {{ background:{ORANGE}; }} .chk.ng {{ color:{ORANGE}; }}
.lane {{ position:absolute; top:230px; width:256px; height:150px; border-radius:12px; background:{FACE_C}; }}
.lane b {{ position:absolute; left:22px; top:16px; font-size:26px; font-weight:900; color:{DIM}; }}
.job {{ position:absolute; top:296px; width:212px; height:64px; border-radius:10px; background:{ORANGE}; color:#fff; font-size:28px; font-weight:900;
        display:flex; align-items:center; justify-content:center; left:70px; }}
.term {{ position:absolute; left:48px; right:48px; top:410px; height:130px; border-radius:12px; background:#06090E; padding:22px 28px;
        font-family:'Noto Sans Mono CJK JP',monospace; font-size:28px; line-height:1.55; color:#6F7A8C; }}
.strike {{ position:absolute; left:34px; right:34px; top:470px; height:10px; border-radius:5px; background:{ORANGE}; transform-origin:left; transform:scaleX(0); }}
.free {{ position:absolute; left:60px; top:532px; background:{ORANGE}; color:#fff; font-size:44px; font-weight:900; padding:4px 24px; border-radius:10px; opacity:0; }}
.url {{ position:absolute; left:0; right:0; top:230px; text-align:center; font-size:76px; font-weight:900; color:{INK}; letter-spacing:-.01em; }}
.url span {{ color:{ORANGE}; }}
.sub {{ position:absolute; left:0; right:0; top:350px; text-align:center; font-size:36px; font-weight:800; color:{DIM}; }}
.qm {{ position:absolute; left:0; right:0; top:60px; text-align:center; font-size:400px; font-weight:900; line-height:1; color:{ORANGE}; }}
@keyframes show {{ to {{ opacity:1; }} }}
@keyframes hide {{ to {{ opacity:0; }} }}
@keyframes rowIn {{ from {{ opacity:0; transform:translateX(40px); }} to {{ opacity:1; transform:none; }} }}
@keyframes dimR {{ to {{ opacity:.22; }} }}
@keyframes hiR {{ to {{ background:{ORANGE}; color:#fff; transform:scale(1.06); }} }}
@keyframes up {{ from {{ opacity:0; transform:translateY(24px); }} to {{ opacity:1; transform:none; }} }}
@keyframes on {{ to {{ background:{ORANGE}; color:#fff; }} }}
@keyframes stopOn {{ to {{ background:{ORANGE}; }} }}
@keyframes play {{ to {{ left:844px; }} }}
@keyframes strike {{ to {{ transform:scaleX(1); }} }}
@keyframes move3 {{ 0%,15% {{ left:70px; }} 45%,60% {{ left:342px; }} 90%,100% {{ left:614px; }} }}
@keyframes slam {{ 0% {{ opacity:0; transform:rotate(-6deg) scale(2.2); }} 80% {{ opacity:1; transform:rotate(-6deg) scale(.96); }} 100% {{ opacity:1; transform:rotate(-6deg) scale(1); }} }}
@keyframes pop {{ 0% {{ opacity:0; transform:scale(.6); }} 70% {{ opacity:1; transform:scale(1.04); }} 100% {{ opacity:1; transform:none; }} }}
@keyframes breathe {{ 0%,100% {{ transform:scale(1); }} 50% {{ transform:scale(1.05); }} }}
</style>"""


def win(t0, t1, inner):
    an = f"show .15s linear {t0:.3f}s forwards" + (f", hide .15s linear {t1:.3f}s forwards" if t1 else "")
    return f'<div class="win" style="animation:{an}">{inner}</div>'


def no(n, name):
    return f'<div class="no"><b>{n:02d}</b><small>/ 07</small><span>{name}</span></div>'


def rows(t_in, step, t_pick=None, pick="依頼箱"):
    out = []
    for k, name in enumerate(ITEMS):
        an = f"rowIn .3s cubic-bezier(.2,1.3,.4,1) {t_in + k * step:.3f}s forwards"
        if t_pick is not None:
            an += f", hiR .35s ease-out {t_pick:.3f}s forwards" if name == pick else f", dimR .35s linear {t_pick:.3f}s forwards"
        out.append(f'<div class="row" style="animation:{an}"><em>{k + 1:02d}</em>{name}</div>')
    return '<div class="rows">' + "".join(out) + "</div>"


def scene(T, S):
    h = CSS
    # 0: 問い（モニターがついたら ？ が息をする）
    h += win(T(0, 1.3), T(1, 0.0), f'<div class="qm" style="animation:breathe .9s ease-in-out {T(0, 1.4):.3f}s 3">?</div>')
    # 1: 仕組みは7つ（左に大きな 7、右に7行）
    h += win(T(1, 0.0), T(2, 0.0),
             f'<div class="seven" style="animation:pop .4s ease-out {T(1, 0.9):.3f}s forwards"><b>7</b><span>つの仕組み</span></div>'
             + rows(T(1, 1.1), 0.14))
    # 2: 01 運用分析（動きの帯を再生ヘッドが進み、止まりが橙に変わる）
    t2 = T(2, 0.3); d2 = S(2) - 0.5
    segs = [(0.16, 0), (0.08, 1), (0.22, 0), (0.12, 1), (0.25, 0), (0.07, 1), (0.10, 0)]
    acc, tr, tg = 0.0, "", ""
    for w, stop in segs:
        ts = t2 + d2 * (acc + w)
        tr += f'<i style="flex:{w};' + (f'animation:stopOn .15s linear {ts:.3f}s forwards' if stop else '') + '"></i>'
        if stop:
            tg += f'<div class="tag" style="left:{48 + 804 * (acc + w / 2) - 70:.0f}px;animation:up .25s ease-out {ts:.3f}s forwards">止まり</div>'
        acc += w
    h += win(T(2, 0.0), T(3, 0.0), no(1, "運用分析") + '<div class="msg">ロボットの動画から、<br>止まった所を読む</div>'
             + f'<div class="track">{tr}</div>{tg}<div class="head" style="animation:play {d2:.2f}s linear {t2:.3f}s forwards"></div>')
    # 3: 02 GPUで学習（借りる → 学習 → 消す が順に橙になる）
    t3 = T(3, 0.0); s3 = S(3)
    st = "".join(f'<div class="step" style="animation:on .25s ease-out {tt:.3f}s forwards"><em>{k + 1}</em><b>{nm}</b></div>'
                 for k, (nm, tt) in enumerate([("借りる", t3 + 0.7), ("学習", t3 + 1.6), ("消す", t3 + s3 - 0.9)]))
    h += win(t3, T(4, 0.0), no(2, "GPUで学習") + '<div class="msg">借りて学習して、<br>終わったら消す</div>' + f'<div class="steps">{st}</div>')
    # 4: 05 事実確認（別のAIが1行ずつ確かめる。言い切りだけ ✗）
    t4 = T(4, 0.0)
    rr = "".join(f'<div class="chk{" ng" if c == "ng" else ""}" style="top:{290 + k * 92}px;animation:up .3s ease-out {t4 + 0.5 + k * 0.4:.3f}s forwards"><i>{"✕" if c == "ng" else "✓"}</i>{tx}</div>'
                 for k, (c, tx) in enumerate([("ok", "数字の出どころ"), ("ng", "根拠のない言い切り"), ("ok", "引用の原文")]))
    h += win(t4, T(5, 0.0), no(5, "事実確認") + '<div class="msg">別のAIに、疑わせる</div>' + rr)
    # 5: いちばん助かってるのは…（7行に戻り、依頼箱だけ橙で残る）
    h += win(T(5, 0.0), T(6, 0.0), '<div class="msg" style="width:360px;font-size:54px">いちばん<br>助かって<br>いるのは</div>'
             + rows(T(5, 0.0), 0.0, t_pick=T(5, 1.0)).replace('top:46px', 'top:46px'))
    # 6: 03 依頼箱（依頼が 新規→実行中→済み と進み、ターミナルの行に線が引かれる）
    t6 = T(6, 0.0); s6 = S(6)
    lanes = "".join(f'<div class="lane" style="left:{48 + k * 272}px"><b>{nm}</b></div>' for k, nm in enumerate(["新規", "実行中", "済み"]))
    h += win(t6, T(7, 0.0), no(3, "依頼箱") + '<div class="msg" style="font-size:44px">Claude Code に、自動で渡す</div>'
             + lanes + f'<div class="job" style="animation:move3 {s6 - 1.0:.2f}s ease-in-out {t6 + 0.4:.3f}s forwards">依頼</div>'
             + '<div class="term">$ cd ~/Robotics_UX/ServiceSim<br>$ claude -p "……"</div>'
             f'<div class="strike" style="animation:strike .35s ease-out {t6 + s6 - 1.9:.3f}s forwards"></div>'
             f'<div class="free" style="animation:slam .3s ease-out {t6 + s6 - 1.2:.3f}s forwards">解放！</div>')
    # 7: 研究の記録は毎日サイトで
    t7 = T(7, 0.0)
    h += win(t7, None,
             f'<div class="url" style="opacity:0;animation:pop .4s ease-out {t7 + 0.3:.3f}s forwards">pixelstomotors<span>.com</span></div>'
             f'<div class="sub" style="opacity:0;animation:up .4s ease-out {t7 + 1.0:.3f}s forwards">研究の記録を、毎日公開</div>')
    return h


RING = json.load(open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "stage", "ring_lut.json")))
RING_SEC = 48.0          # 床の円が1周する秒数（ゆっくり回す・版3）


def OVER(T):
    """モニターの外に重ねるもの（版3）。床の円を回す・紙吹雪（7つ！と解放！）・解放！で画面が揺れる。
    円は Unity の舞台から消した床（stage_bg_noring.png）の上に、同じカメラの値で描き直す（stage_meta.json の camera）。"""
    bursts = [dict(t=T(1, 1.0), n=230), dict(t=T(6, 4.4), n=130)]
    js = f"""<canvas id="fx" width="1080" height="1920" style="position:absolute;left:0;top:0;z-index:3;pointer-events:none"></canvas>
<script>
(function(){{
const LUT={json.dumps(RING["lut"])}, FY={RING["fy"]:.4f}, C={json.dumps(RING["C"])}, PIT={RING["pitch"]}*Math.PI/180, SEC={RING_SEC};
const f1=-Math.sin(PIT), f2=Math.cos(PIT), u1=Math.cos(PIT), u2=Math.sin(PIT);
function P(x,z){{ const dx=x-C[0], dy=-C[1], dz=z-C[2]; const zc=dy*f1+dz*f2, yc=dy*u1+dz*u2; return [540+FY*dx/zc, 960-FY*yc/zc]; }}
function rnd(seed){{ return function(){{ seed|=0; seed=seed+0x6D2B79F5|0; let t=Math.imul(seed^seed>>>15,1|seed); t=t+Math.imul(t^t>>>7,61|t)^t; return ((t^t>>>14)>>>0)/4294967296; }}; }}
const COLS=["#E2683A","#F2C14E","#F5F3EE","#57B5F8","#FFB38A","#F2C14E","#E2683A"];
const BURSTS={json.dumps(bursts)};
const parts=[];
BURSTS.forEach((b,bi)=>{{ const r=rnd(7+bi*101);
  for(let i=0;i<b.n;i++){{ const side=i%2?1:-1; const x0=540+side*400, y0=1470;
    const ang=(-90+side*-28+(r()-.5)*44)*Math.PI/180, sp=1300+r()*1300;
    parts.push({{t0:b.t+r()*0.18, x0, y0, vx:Math.cos(ang)*sp, vy:Math.sin(ang)*sp, k:2.6+r()*1.4, g:900+r()*300,
      sh:(r()<.12?3:(r()<.25?1:(r()<.45?2:0))), w:5+r()*6, c:COLS[Math.floor(r()*COLS.length)], rs:(r()-.5)*14, r0:r()*6.28,
      fl:6+r()*10, ph:r()*6.28, sw:16+r()*26, life:2.6+r()*1.4}}); }} }});
const cv=document.getElementById('fx'), g=cv.getContext('2d');
function ring(t){{ const N=36, rot=t*2*Math.PI/SEC;
  for(let k=0;k<N;k++){{ const a0=rot+k*2*Math.PI/N, a1=a0+Math.PI/N, am=(a0+a1)/2;
    let idx=Math.floor((((am%(2*Math.PI))+2*Math.PI)%(2*Math.PI))/(2*Math.PI)*36)%36; const c=LUT[idx];
    const pts=[]; for(let j=0;j<=6;j++){{ const a=a0+(a1-a0)*j/6; pts.push(P(1.55*Math.cos(a),1.55*Math.sin(a))); }}
    for(let j=6;j>=0;j--){{ const a=a0+(a1-a0)*j/6; pts.push(P(1.45*Math.cos(a),1.45*Math.sin(a))); }}
    g.beginPath(); g.moveTo(pts[0][0],pts[0][1]); pts.slice(1).forEach(q=>g.lineTo(q[0],q[1])); g.closePath();
    g.fillStyle=`rgb(${{Math.min(255,c[0]*1.15)}},${{Math.min(255,c[1]*1.15)}},${{Math.min(255,c[2]*1.15)}})`; g.fill(); }} }}
function confetti(t){{ for(const p of parts){{ const u=t-p.t0; if(u<0||u>p.life) continue;
    const e=(1-Math.exp(-p.k*u))/p.k; let x=p.x0+p.vx*e, y=p.y0+(p.vy+p.g/p.k)*e-p.g*u/p.k;
    x+=p.sw*Math.sin(p.fl*0.5*u+p.ph)*Math.min(1,u/0.6);
    const al=u>p.life-0.6?(p.life-u)/0.6:1; g.save(); g.globalAlpha=al; g.translate(x,y); g.rotate(p.r0+p.rs*u);
    const fs=Math.max(.15,Math.abs(Math.cos(p.fl*u+p.ph)));
    if(p.sh===3){{ const tw=0.5+0.5*Math.sin(u*18+p.ph); g.fillStyle="#FFF7E0"; g.globalAlpha=al*tw; const s=p.w*1.3;
      g.beginPath(); g.moveTo(0,-s); g.lineTo(s*.25,-s*.25); g.lineTo(s,0); g.lineTo(s*.25,s*.25); g.lineTo(0,s); g.lineTo(-s*.25,s*.25); g.lineTo(-s,0); g.lineTo(-s*.25,-s*.25); g.closePath(); g.fill(); }}
    else {{ g.scale(1,fs); g.fillStyle=p.c;
      if(p.sh===1){{ g.beginPath(); g.arc(0,0,p.w*.55,0,6.283); g.fill(); }}
      else if(p.sh===2){{ g.fillRect(-p.w*.22,-p.w*1.6,p.w*.44,p.w*3.2); }}
      else {{ g.fillRect(-p.w/2,-p.w*.8,p.w,p.w*1.6); }} }}
    g.restore(); }} }}
window.hook=function(t){{ g.clearRect(0,0,1080,1920); ring(t); confetti(t); }};
window.hookBusy=function(t){{ return true; }};
window.hook(0);
}})();
</script>"""
    return js + shake_js([(T(6, 4.4), 0.6)], amp=7)


FACE = [(-1, 0.0, 1.0, "joy"), (1, 0.0, 2.0, "joy"), (4, 0.0, 2.5, "wink"), (6, 3.5, 2.5, "joy"), ("end", 0.0, 3.0, "smile")]
POSE = [(-1, 0.0, 1), (1, 0.6, 3), (2, 0.0, 2), (5, 0.0, 1), (6, 3.2, 4)]
LEAN = []
MOVES = [(1, 0.6, "hop", 1.4), (3, 0.5, "tilt", 1.2), (5, 0.2, "turn", 2.0), (7, 0.2, "jump", 1.0)]
INTRO = "rise"
SE = [(-1, 0.0, "se_rise", -8), (0, 1.3, "se_boot", -12), (1, 1.0, "se_ding", -8), (1, 1.15, "se_jaran", -12),
      (2, 0.0, "se_whoosh", -12), (3, 0.0, "se_whoosh", -12), (4, 0.0, "se_whoosh", -12), (4, 1.0, "se_pop", -10),
      (5, 1.0, "se_tick", -10), (6, 0.0, "se_whoosh", -12), (6, 4.4, "se_pechin", -6), (7, 0.4, "se_pop", -9)]
ENDING = dict(kind="jump", at=(6, 3.4))
```

### 01 研究のスキル一覧 の書き出しのログ

```text
研究のスキル一覧: 880コマ（撮影 880）
out/研究のスキル一覧_版5.mp4  29.3秒
```

### 02 Claude Codeへの頼み方（26.9秒）の narration.py

```python
# -*- coding: utf-8 -*-
# 2026-11-07: 最後に締め（サイトへの入口）の文を足した。声は「研究のスキル一覧」の 007 をそのまま使う（エイイチ「うちのサイトへの動線部分がないけど？」）
# Claude Codeへの頼み方（ショート専用・舞台型・2026-11-06）。エイイチのセリフは、エイイチがこれまでに話した言葉から（Claude Code への指示を自分で貼って渡す形はNG（2026-11-03））
FACES = ("base", "smile", "surprise", "doubt", "think", "trouble", "joy", "wink")
SEGS = [
 ("ask_ei", "base", "Claude Codeへの指示、まだ手で貼ってる？", 0.15),
 ("write", "joy", "僕が依頼を書いて、依頼箱に置きます！", 0.1),
 ("pass", "base", "Macの見張り係が、1件ずつClaude Codeに渡します。", 0.1),
 ("report", "smile", "終わったら、合格か、止めたか、失敗か。判定つきで報告。", 0.15),
 ("ng_ei", "base", "自分で貼って渡すのは、もうナシ。", 0.2),
 ("lock", "wink", "ただし、実機を動かす依頼は、確認待ちで止めます！", 0.5),
 ("outro", "smile", "研究の記録は、毎日サイトで公開中です！", 0.6),
]
ELEVEN_READ = [("GPU", "ジーピーユー"), ("AI", "エーアイ"), ("Claude Code", "クロード コード"), ("Claude", "クロード"), ("Unity", "ユニティ"), ("Mac", "マック"),
               ("pixelstomotors.com", "ピクセルズトゥモーターズ ドットコム"), ("1件", "いっけん"), ("1つ", "ひとつ"), ("1画面", "ひとがめん"), ("2時間", "にじかん"), ("…", "。")]
EI_LINES = [i for i, seg in enumerate(SEGS) if seg[0].endswith("_ei")]
READ = [("「", ""), ("」", "")]


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

### 02 Claude Codeへの頼み方 の spec.py

```python
# -*- coding: utf-8 -*-
"""Claude Codeへの頼み方（ショート専用・舞台型・2026-11-06）
エイイチの言葉: Claude Code への指示を自分で貼って渡す形はNG（2026-11-03）。依頼箱の決まりは 依頼箱/README.md"""
from butai_parts import *
OUTRO_K = 6   # 締めの文の番号（narration.py の最後に足した文）

NAME, VER = "Claude Codeへの頼み方", 4
HEAD = "Claude Code<br>どう頼む？"
T_ON = (0, 1.3)


def flow(names, t0, step, top=300, w=252, lit_all=False):
    out = ""
    for k, (nm, sm) in enumerate(names):
        an = f"animation:{a('on', t0 + k * step, .25)}" if t0 is not None else ""
        out += (f'<div class="card{" or" if lit_all else ""}" style="left:{48 + k * 276}px;top:{top}px;width:{w}px;height:170px;{an}">'
                f'<small>{sm}</small><span style="font-size:42px">{nm}</span></div>')
    return out


def scene(T, S):
    h = CSS
    # 0: まだ手で貼ってる？（ターミナルにコピー→貼り付け）
    t0 = T(0, 1.3)
    h += win(t0, T(1, 0), kick("いままで")
             + '<div class="card" style="left:48px;top:120px;width:804px;height:220px;background:#06090E;font-family:\'Noto Sans Mono CJK JP\',monospace;'
               'font-size:30px;font-weight:500;color:#6F7A8C;justify-content:flex-start;padding-top:30px;line-height:1.6">$ claude<br>&gt; （指示を貼り付け）</div>'
             + f'<div class="stamp" style="left:300px;top:380px;font-size:50px;animation:{a("slam", t0 + 0.5)}">コピー → 貼り付け</div>')
    # 1: 僕が依頼を書いて、依頼箱に置く
    t1 = T(1, 0)
    h += win(t1, T(2, 0), kick("いまは")
             + f'<div class="card or" style="left:330px;top:100px;width:240px;height:110px;font-size:40px;align-items:center;opacity:0;animation:{a("up", t1 + 0.3)}, {a("drop1", t1 + 1.3, .5, "ease-in")}">依頼</div>'
             + '<style>@keyframes drop1 { to { transform:translateY(220px) scale(.8); opacity:0; } }</style>'
             + '<div class="card" style="left:250px;top:300px;width:400px;height:220px;font-size:54px;align-items:center"><small>Claude が置く</small>依頼箱</div>')
    # 2: Mac の見張り係が、1件ずつ Claude Code に渡す
    t2, s2 = T(2, 0), S(2)
    h += win(t2, T(3, 0), kick("1件ずつ") + '<div class="msg" style="font-size:50px">見張り係が、自動で渡す</div>'
             + flow([("依頼箱", "置く"), ("見張り係", "Mac"), ("Claude Code", "作る")], t2 + 0.4, (s2 - 0.8) / 2))
    # 3: 判定つきで報告（合格・止めた・失敗）
    t3, s3 = T(3, 0), S(3)
    chips = "".join(f'<div class="card{" or" if k == 0 else ""}" style="left:{48 + k * 276}px;top:250px;width:252px;height:200px;font-size:52px;align-items:center;opacity:0;'
                    f'animation:{a("pop", t3 + 1.0 + k * 0.45, .3)}"><small>判定</small>{nm}</div>' for k, nm in enumerate(["合格", "止めた", "失敗"]))
    h += win(t3, T(4, 0), kick("終わったら") + '<div class="msg" style="font-size:50px">判定つきで報告</div>' + chips)
    # 4: 自分で貼って渡すのは、もうナシ
    t4, s4 = T(4, 0), S(4)
    h += win(t4, T(5, 0), kick("エイイチの決まり")
             + '<div class="card" style="left:120px;top:180px;width:660px;height:200px;font-size:58px;align-items:center;color:#8A93A3">自分で貼って渡す</div>'
             + f'<div class="strike" style="left:100px;width:700px;top:275px;animation:{a("strike", t4 + 1.4, .35)}"></div>'
             + f'<div class="stamp" style="left:330px;top:400px;font-size:64px;animation:{a("slam", t4 + 1.8)}">ナシ</div>')
    # 5: ただし、実機を動かす依頼は確認待ちで止める
    t5 = T(5, 0)
    h += win(t5, None, kick("ただし")
             + f'<div class="card" style="left:48px;top:150px;width:390px;height:300px;font-size:44px;opacity:0;animation:{a("up", t5 + 0.3)}"><small>ふだんの依頼</small>自動で回す</div>'
             + f'<div class="card or" style="left:462px;top:150px;width:390px;height:300px;font-size:44px;opacity:0;animation:{a("up", t5 + 1.2)}"><small>実機を動かす依頼</small>確認待ちで<br>止める</div>')
    h += outro(T, OUTRO_K)   # 締め：サイトへの入口
    return h


def OVER(T):
    return stage_fx(T, shakes=[(T(4, 1.8), 0.5)], fx=[dict(kind="shock", t=T(3, 1.0), d=1.6), dict(kind="speed", t=T(4, 1.8), d=0.9)])


FACE = [(-1, 0.0, 1.0, "smile"), (1, 0.0, 2.7, "joy"), (3, 0.0, 4.9, "smile"), (4, 0.0, 2.6, "surprise"), (5, 0.0, 3.0, "wink"), ("end", 0.0, 3.0, "smile")]
POSE = [(-1, 0.0, 2), (1, 0.0, 1), (3, 1.0, 3), (4, 0.0, 2), (5, 0.3, 1)]
LEAN = []
INTRO = "rise"
MOVES = [(1, 0.3, "hop", 1.0), (2, 0.4, "dash", 1.0), (3, 1.0, "jump", 1.0), (5, 0.4, "tilt", 1.2)]
SE = [(-1, 0.0, "se_rise", -9), (0, 1.3, "se_boot", -12), (0, 1.8, "se_pechin", -10), (1, 0.0, "se_whoosh", -12), (1, 1.3, "se_pop", -9),
      (2, 0.0, "se_whoosh", -12), (2, 0.4, "se_tick", -12), (2, 2.0, "se_tick", -12), (2, 3.6, "se_tick", -12),
      (3, 0.0, "se_whoosh", -12), (3, 1.0, "se_ding", -10), (4, 0.0, "se_whoosh", -12), (4, 1.8, "se_pechin", -7), (5, 0.0, "se_whoosh", -12)]
ENDING = dict(kind="jump", at=(4, 1.8))
```

### 02 Claude Codeへの頼み方 の書き出しのログ

```text
Claude Codeへの頼み方: 807コマ（撮影 807）
out/Claude Codeへの頼み方_版4.mp4  26.9秒
```

### 03 寝ている間に回す（24.9秒）の narration.py

```python
# -*- coding: utf-8 -*-
# 2026-11-07: 最後に締め（サイトへの入口）の文を足した。声は「研究のスキル一覧」の 007 をそのまま使う（エイイチ「うちのサイトへの動線部分がないけど？」）
# 寝ている間に回す（ショート専用・舞台型・2026-11-06）。エイイチのセリフは、エイイチがこれまでに話した言葉から（寝ている間も依頼を続けて回せるようにしてほしい／長い依頼の途中経過は知りたいが毎時間は多い）
FACES = ("base", "smile", "surprise", "doubt", "think", "trouble", "joy", "wink")
SEGS = [
 ("ask_ei", "base", "寝てる間も、研究を進めたい。", 0.15),
 ("night", "joy", "夜は、Claude Codeが回します！", 0.1),
 ("gpu", "base", "クラウドのGPUで学習して、終わったら片付けまで。", 0.1),
 ("unity", "base", "Unityの店も、条件を変えて、何十回も回します。", 0.15),
 ("info_ei", "base", "途中経過は知りたい。でも、毎時間は多い。", 0.15),
 ("two", "smile", "じゃあ、2時間ごとか、区切りでお知らせします！", 0.5),
 ("outro", "smile", "研究の記録は、毎日サイトで公開中です！", 0.6),
]
ELEVEN_READ = [("GPU", "ジーピーユー"), ("AI", "エーアイ"), ("Claude Code", "クロード コード"), ("Claude", "クロード"), ("Unity", "ユニティ"), ("Mac", "マック"),
               ("pixelstomotors.com", "ピクセルズトゥモーターズ ドットコム"), ("1件", "いっけん"), ("1つ", "ひとつ"), ("1画面", "ひとがめん"), ("2時間", "にじかん"), ("…", "。")]
EI_LINES = [i for i, seg in enumerate(SEGS) if seg[0].endswith("_ei")]
READ = [("「", ""), ("」", "")]


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

### 03 寝ている間に回す の spec.py

```python
# -*- coding: utf-8 -*-
"""寝ている間に回す（ショート専用・舞台型・2026-11-06）
エイイチの言葉: 寝ている間も依頼を続けて回せるようにしてほしい（2026-10-27）／長い依頼の途中経過は知りたいが、毎時間は多い。2時間ごとかタスクの区切りでよい（2026-11-05）"""
from butai_parts import *
OUTRO_K = 6   # 締めの文の番号（narration.py の最後に足した文）

NAME, VER = "寝ている間に回す", 3
HEAD = "寝てる間に<br>研究は進む？"
T_ON = (0, 1.2)
HOURS = ["23", "0", "1", "2", "3", "4", "5", "6", "7"]


def night_bar(top=470, ticks=None, t_ticks=None, col=FACE_2, label=True):
    """23時〜7時の帯。ticks: 印を付ける時（HOURS の番号）"""
    w = 804 / 8
    out = f'<div class="card" style="left:48px;top:{top}px;width:804px;height:26px;padding:0;border-radius:13px;background:{FACE_C}"></div>'
    if label:
        out += "".join(f'<div style="position:absolute;left:{48 + k * w - 30:.0f}px;top:{top + 34}px;width:60px;text-align:center;font-size:22px;font-weight:800;color:{DIM}">{hh}時</div>'
                       for k, hh in enumerate(HOURS) if k % 2 == 0)
    for j, k in enumerate(ticks or []):
        an = f"opacity:0;animation:{a('pop', t_ticks + j * 0.12, .2)}" if t_ticks is not None else ""
        out += f'<div style="position:absolute;left:{48 + k * w - 10:.0f}px;top:{top - 22}px;width:20px;height:70px;border-radius:6px;background:{col};{an}"></div>'
    return out


def scene(T, S):
    h = CSS
    # 0: 寝てる間も進めたい（夜の帯）
    t0 = T(0, 1.2)
    h += win(t0, T(1, 0), kick("夜") + '<div class="msg" style="font-size:64px">寝ている間</div>' + night_bar(330))
    # 1: 夜は Claude Code が回す（帯の上を再生ヘッドが進む）
    t1, s1 = T(1, 0), S(1)
    h += win(t1, T(2, 0), kick("夜は") + '<div class="msg" style="font-size:64px">Claude Code が回す</div>' + night_bar(330)
             + "<style>@keyframes nh { from { left:48px; } to { left:844px; } }</style>"
             + f'<div style="position:absolute;top:300px;width:8px;height:90px;border-radius:4px;background:{ORANGE};left:48px;animation:nh {s1 + 0.4:.2f}s linear {t1 + 0.2:.3f}s forwards"></div>')
    # 2: クラウドの GPU で学習して、片付けまで
    t2, s2 = T(2, 0), S(2)
    st = "".join(f'<div class="card" style="left:{48 + k * 205}px;top:260px;width:189px;height:190px;font-size:34px;white-space:nowrap;animation:{a("on", t2 + 0.3 + k * (s2 - 0.6) / 3, .25)}">'
                 f'<small>{k + 1}</small>{nm}</div>' for k, nm in enumerate(["借りる", "学習", "持ち帰る", "片付け"]))
    h += win(t2, T(3, 0), kick("1つめ　クラウドの GPU") + st)
    # 3: Unity の店を、条件を変えて何十回も（マスが埋まる）
    t3, s3 = T(3, 0), S(3)
    cells = ""
    for k in range(60):
        r_, c_ = divmod(k, 12)
        cells += (f'<div style="position:absolute;left:{48 + c_ * 67}px;top:{170 + r_ * 66}px;width:58px;height:56px;border-radius:8px;background:{FACE_C};'
                  f'animation:{a("on", t3 + 0.3 + k * (s3 - 0.6) / 60, .12)}"></div>')
    h += win(t3, T(4, 0), kick("2つめ　Unity の店（条件 × 回数）") + cells)
    # 4: 途中経過は知りたい。でも毎時間は多い（帯に毎時の印が並ぶ）
    t4, s4 = T(4, 0), S(4)
    h += win(t4, T(5, 0), kick("途中経過のお知らせ") + '<div class="msg" style="font-size:56px">毎時間は、多い</div>'
             + night_bar(380, ticks=list(range(9)), t_ticks=t4 + 0.3, col="#5C6678")
             + f'<div class="strike" style="left:40px;width:820px;top:388px;animation:{a("strike", t4 + s4 - 1.0, .35)}"></div>')
    # 5: 2時間ごとか、区切りで
    t5 = T(5, 0)
    h += win(t5, None, kick("これから") + '<div class="msg" style="font-size:56px">2時間ごと、か区切りで</div>'
             + night_bar(380, ticks=[0, 2, 4, 6, 8], t_ticks=t5 + 0.4, col=ORANGE))
    h += outro(T, OUTRO_K)   # 締め：サイトへの入口
    return h


def OVER(T):
    return stage_fx(T, fx=[dict(kind="stars", t=T(0, 0.6), d=T(4, 0) - T(0, 0.6)), dict(kind="shock", t=T(5, 0.4), d=1.6)])


FACE = [(-1, 0.0, 1.0, "smile"), (1, 0.0, 2.3, "wink"), (2, 0.0, 3.6, "base"), (3, 0.0, 3.9, "joy"), (4, 0.0, 3.9, "think"), (5, 0.0, 3.0, "smile"), ("end", 0.0, 3.0, "smile")]
POSE = [(-1, 0.0, 2), (2, 0.0, 2), (4, 0.0, 1), (5, 0.4, 4)]
LEAN = []
INTRO = "drop"
MOVES = [(1, 0.3, "spin", 1.0), (3, 0.5, "hop", 1.4), (5, 0.4, "bow", 1.2)]
SE = [(-1, 0.0, "se_rise", -10), (0, 1.2, "se_boot", -12), (1, 0.0, "se_whoosh", -12), (2, 0.0, "se_whoosh", -12),
      (2, 0.3, "se_tick", -12), (2, 1.3, "se_tick", -12), (2, 2.3, "se_tick", -12), (2, 3.2, "se_tick", -12),
      (3, 0.0, "se_whoosh", -12), (3, 3.4, "se_ding", -11), (4, 0.0, "se_whoosh", -12), (4, 0.3, "se_buzz", -14),
      (5, 0.0, "se_whoosh", -12), (5, 0.4, "se_ding", -9)]
ENDING = dict(kind="jump", at=(5, 0.4))
```

### 03 寝ている間に回す の書き出しのログ

```text
寝ている間に回す: 748コマ（撮影 748）
out/寝ている間に回す_版3.mp4  24.9秒
```

### 04 Claudeに覚えてもらう（24.0秒）の narration.py

```python
# -*- coding: utf-8 -*-
# 2026-11-07: 最後に締め（サイトへの入口）の文を足した。声は「研究のスキル一覧」の 007 をそのまま使う（エイイチ「うちのサイトへの動線部分がないけど？」）
# Claudeに覚えてもらう（ショート専用・舞台型・2026-11-06）。エイイチのセリフは、エイイチがこれまでに話した言葉から（同じチャットは同じLog番号にしてください（いい加減覚えてください））
FACES = ("base", "smile", "surprise", "doubt", "think", "trouble", "joy", "wink")
SEGS = [
 ("ask_ei", "base", "同じこと、何回言えば覚えるの？", 0.15),
 ("forget", "trouble", "会話が変わると、僕は忘れてしまいます…", 0.15),
 ("book", "base", "だから、決めたことは、ルールの本に書き足します。", 0.1),
 ("count", "joy", "動画のルールは、もう105回、書き足しました！", 0.2),
 ("angry_ei", "base", "いい加減、覚えてください。", 0.25),
 ("read", "smile", "はい！次の会話は、まず本を読むところから！", 0.5),
 ("outro", "smile", "研究の記録は、毎日サイトで公開中です！", 0.6),
]
ELEVEN_READ = [("GPU", "ジーピーユー"), ("AI", "エーアイ"), ("Claude Code", "クロード コード"), ("Claude", "クロード"), ("GitHub", "ギットハブ"), ("Unity", "ユニティ"), ("SNS", "エスエヌエス"), ("ElevenLabs", "イレブンラボ"), ("codex", "コーデックス"), ("1つ", "ひとつ"), ("105回", "ひゃくごかい"), ("13分", "じゅうさんぷん"), ("30分", "さんじゅっぷん"), ("45分", "よんじゅうごふん"), ("…", "。")]
EI_LINES = [i for i, seg in enumerate(SEGS) if seg[0].endswith("_ei")]
READ = [("「", ""), ("」", "")]


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

### 04 Claudeに覚えてもらう の spec.py

```python
# -*- coding: utf-8 -*-
"""Claudeに覚えてもらう（ショート専用・舞台型・2026-11-06）
エイイチの言葉: 「同じチャットは同じlog番号にしてください（いい加減覚えてください）」"""
from butai_parts import *
OUTRO_K = 6   # 締めの文の番号（narration.py の最後に足した文）

NAME, VER = "Claudeに覚えてもらう", 4
HEAD = "AIに<br>覚えてもらうには？"
T_ON = (0, 1.2)
SAY = "同じチャットは、同じLog番号に"


def bubbles(t0, step, t_out=None):
    out = ""
    for k in range(3):
        an = a("up", t0 + k * step)
        if t_out is not None:
            an += ", " + a("blurOut", t_out + k * 0.12, .6)
        out += (f'<div class="bubble" style="top:{150 + k * 120}px;animation:{an}">'
                f'<span style="font-size:22px;color:{DIM};margin-right:16px">{k + 1}回目</span>{SAY}</div>')
    return out


def scene(T, S):
    h = CSS
    # 0: 同じことを3回言っている
    t0 = T(0, 0)
    h += win(T(0, 1.2), T(1, 0), kick("エイイチがClaudeに言ったこと") + bubbles(T(0, 1.25), 0.35))
    # 1: 会話が変わると忘れる（吹き出しがぼやけて消える）
    t1 = T(1, 0)
    h += win(t1, T(2, 0), kick("会話が変わると")
             + "".join(f'<div class="bubble" style="top:{150 + k * 120}px;opacity:1;animation:{a("blurOut", t1 + 0.8 + k * 0.25, .7)}">'
                       f'<span style="font-size:22px;color:{DIM};margin-right:16px">{k + 1}回目</span>{SAY}</div>' for k in range(3)))
    # 2: 決めたことは、ルールの本に書き足す
    t2, s2 = T(2, 0), S(2)
    book_lines = "".join(f'<div class="line" style="left:92px;top:{252 + k * 40}px;width:{w}px;transform-origin:left;transform:scaleX(0);'
                         f'animation:{a("grow", t2 + 0.5 + k * (s2 - 0.8) / 6, .3)}{";background:" + ORANGE if k == 5 else ""}"></div>'
                         for k, w in enumerate([240, 200, 250, 180, 230, 210]))
    h += win(t2, T(3, 0), kick("ルールの本")
             + '<div class="card" style="left:48px;top:120px;width:340px;height:440px;justify-content:flex-start;padding-top:30px"><small>動画のルール</small>'
               '<span style="font-size:34px">video_rules.md</span></div>' + book_lines
             + '<div class="msg" style="left:430px;top:220px;font-size:52px">決めたことを、<br>本に<br>書き足す</div>')
    # 3: もう105回書き足した（数え上げ）
    t3, s3 = T(3, 0), S(3)
    h += win(t3, T(4, 0), kick("動画のルールを書き足した回数")
             + f'<div class="hero" style="left:0;right:0;text-align:center;top:150px;font-size:300px"><b id="cnt" style="font-weight:900">0</b><span>回</span></div>'
             + counter_js("cnt", t3 + 0.3, t3 + s3 - 0.6, 105, 0))
    # 4: いい加減、覚えてください（4つ目の吹き出しを叩きつける）
    t4 = T(4, 0)
    h += win(t4, T(5, 0), kick("4回目")
             + "".join(f'<div class="bubble" style="top:{150 + k * 100}px;opacity:.25;font-size:26px">{SAY}</div>' for k in range(3))
             + f'<div class="stamp" style="left:70px;top:330px;font-size:56px;animation:{a("slam", t4 + 0.15)}">いい加減、覚えてください</div>')
    # 5: 次の会話は、まず本を読むところから
    t5, s5 = T(5, 0), S(5)
    st = "".join(f'<div class="card{" or" if k == 0 else ""}" style="left:{48 + k * 276}px;top:300px;width:252px;height:170px;opacity:0;animation:{a("up", t5 + 0.3 + k * 0.35)}">'
                 f'<small>{k + 1}</small><span style="font-size:46px">{nm}</span></div>' for k, nm in enumerate(["本を読む", "作る", "書き足す"]))
    h += win(t5, None, kick("次の会話は") + '<div class="msg">まず、本を読む<br>ところから</div>' + st)
    h += outro(T, OUTRO_K)   # 締め：サイトへの入口
    return h


def OVER(T):
    return stage_fx(T, fx=[dict(kind="speed", t=T(4, 0.15), d=0.9), dict(kind="shock", t=T(5, 0.3), d=1.6, c="#57B5F8")], shakes=[(T(4, 0.15), 0.5)])


FACE = [(-1, 0.0, 1.0, "smile"), (1, 0.0, 3.0, "trouble"), (3, 0.0, 3.4, "joy"), (4, 0.0, 2.0, "surprise"), (5, 0.0, 3.0, "joy"), ("end", 0.0, 3.0, "smile")]
POSE = [(-1, 0.0, 2), (1, 0.0, 2), (3, 2.8, 3), (4, 0.0, 2), (5, 0.4, 4)]
LEAN = []
INTRO = "drop"
MOVES = [(1, 0.5, "bow", 1.4), (3, 2.6, "jump", 1.0), (4, 0.2, "shake", 1.0), (5, 0.3, "hop", 1.2)]
SE = [(-1, 0.0, "se_rise", -9), (0, 1.2, "se_boot", -12), (0, 1.25, "se_pop", -12), (0, 1.6, "se_pop", -12), (0, 1.95, "se_pop", -12),
      (1, 0.8, "se_steam", -14), (2, 0.0, "se_whoosh", -12), (3, 0.0, "se_whoosh", -12), (3, 2.8, "se_ding", -9),
      (4, 0.15, "se_pechin", -6), (5, 0.0, "se_whoosh", -12)]
ENDING = dict(kind="fall", at=(5, 2.9))   # ハコブくんの元気な返事でエイイチがコケる
```

### 04 Claudeに覚えてもらう の書き出しのログ

```text
Claudeに覚えてもらう: 721コマ（撮影 721）
out/Claudeに覚えてもらう_版4.mp4  24.0秒
```

### 05 記事の公開は誰が（27.9秒）の narration.py

```python
# -*- coding: utf-8 -*-
# 2026-11-07: 最後に締め（サイトへの入口）の文を足した。声は「研究のスキル一覧」の 007 をそのまま使う（エイイチ「うちのサイトへの動線部分がないけど？」）
# 記事の公開は誰が（ショート専用・舞台型・2026-11-06）。エイイチのセリフは、エイイチがこれまでに話した言葉から（自動化の前は記事1本の仕上げと公開に30〜45分／Studio の更新はもう面倒でやりたくない）
FACES = ("base", "smile", "surprise", "doubt", "think", "trouble", "joy", "wink")
SEGS = [
 ("ask_ei", "base", "記事1本の公開、どれくらいかかってた？", 0.15),
 ("time", "trouble", "サムネイル作りに、貼り付け。1本、30分から45分！", 0.15),
 ("push", "joy", "いまは、Claudeが仕上げて、GitHubからそのまま公開。", 0.1),
 ("sns", "smile", "SNSの投稿も、時間を決めて、自動で予約します。", 0.15),
 ("studio_ei", "base", "サイトの更新は、もう面倒でやりたくなかった。", 0.2),
 ("lab", "joy", "浮いた時間は、ロボットの研究へ！", 0.5),
 ("outro", "smile", "研究の記録は、毎日サイトで公開中です！", 0.6),
]
ELEVEN_READ = [("GPU", "ジーピーユー"), ("AI", "エーアイ"), ("Claude Code", "クロード コード"), ("Claude", "クロード"), ("GitHub", "ギットハブ"), ("Unity", "ユニティ"), ("SNS", "エスエヌエス"), ("ElevenLabs", "イレブンラボ"), ("codex", "コーデックス"), ("1つ", "ひとつ"), ("105回", "ひゃくごかい"), ("13分", "じゅうさんぷん"), ("30分", "さんじゅっぷん"), ("45分", "よんじゅうごふん"), ("…", "。")]
EI_LINES = [i for i, seg in enumerate(SEGS) if seg[0].endswith("_ei")]
READ = [("「", ""), ("」", "")]


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

### 05 記事の公開は誰が の spec.py

```python
# -*- coding: utf-8 -*-
"""記事の公開は誰が（ショート専用・舞台型・2026-11-06）
エイイチの言葉: 自動化の前は記事1本の仕上げと公開に30〜45分／Studio の更新はもう面倒でやりたくない"""
from butai_parts import *
OUTRO_K = 6   # 締めの文の番号（narration.py の最後に足した文）

NAME, VER = "記事の公開は誰が", 4
HEAD = "記事の公開<br>誰がやる？"
T_ON = (0, 1.4)


def scene(T, S):
    h = CSS
    # 0: 記事1本（中身の線が書かれていく）
    t = T(0, 1.4)
    ln = "".join(f'<div class="line" style="left:300px;top:{250 + k * 46}px;width:{w}px;transform-origin:left;transform:scaleX(0);animation:{a("grow", t + 0.15 * k, .35)}"></div>'
                 for k, w in enumerate([300, 260, 300, 180]))
    h += win(t, T(1, 0), kick("記事1本") + '<div class="card" style="left:250px;top:150px;width:400px;height:330px"></div>'
             + f'<div class="line" style="left:300px;top:190px;width:200px;height:26px;background:{ORANGE}"></div>' + ln)
    # 1: 前は 30〜45分（右に手作業が3つ積み上がる）
    t1, s1 = T(1, 0), S(1)
    # 2026-11-07 エイイチ「文字が被ってる」: 数字と手作業のカードが重なっていたので、カードを上の段に横並び、数字を下に分けた
    jobs = "".join(f'<div class="card" style="left:{48 + k * 276}px;top:110px;width:252px;height:92px;font-size:30px;align-items:center;padding:0;white-space:nowrap;opacity:0;animation:{a("up", t1 + 0.3 + k * 0.5)}">{nm}</div>'
                   for k, nm in enumerate(["サムネイル作り", "貼り付け", "公開"]))
    h += win(t1, T(2, 0), kick("前は（記事1本）") + jobs
             + f'<div class="hero" style="left:0;right:0;text-align:center;top:290px;font-size:170px;opacity:0;animation:{a("pop", t1 + s1 - 2.2, .4)}">30〜45<span>分</span></div>')
    # 2: いまは Claude → GitHub → サイト（順にオレンジ）
    t2, s2 = T(2, 0), S(2)
    names = [("Claude", "仕上げる"), ("GitHub", "置く"), ("サイト", "公開")]
    fl = "".join(f'<div class="card" style="left:{48 + k * 276}px;top:300px;width:252px;height:170px;animation:{a("on", t2 + 0.4 + k * (s2 - 0.8) / 2, .25)}">'
                 f'<small>{sm}</small><span style="font-size:44px">{nm}</span></div>' for k, (nm, sm) in enumerate(names))
    h += win(t2, T(3, 0), kick("いまは") + '<div class="msg">Claude が仕上げて、<br>そのまま公開</div>' + fl)
    # 3: SNS も自動で予約（1週間の枠に予約が入る）
    t3, s3 = T(3, 0), S(3)
    days = ["月", "火", "水", "木", "金", "土", "日"]
    cols = ""
    for k, d in enumerate(days):
        x = 48 + k * 116
        cols += f'<div class="card" style="left:{x}px;top:200px;width:104px;height:350px;padding:0;justify-content:flex-start;align-items:center"><small style="margin:14px 0 0;font-size:26px">{d}</small></div>'
        slots = ["12:30", "19:30"] if k < 5 else ["12:30"]
        for j, s_ in enumerate(slots):
            tt = t3 + 0.3 + (k * 2 + j) * (s3 - 0.8) / 12
            cols += (f'<div style="position:absolute;left:{x + 8}px;top:{270 + j * 76}px;width:88px;height:62px;border-radius:8px;background:{ORANGE};color:#fff;'
                     f'font-size:22px;font-weight:900;display:flex;align-items:center;justify-content:center;opacity:0;animation:{a("pop", tt, .25)}">{s_}</div>')
    h += win(t3, T(4, 0), kick("SNS の投稿") + '<div class="msg" style="top:90px;font-size:48px">時間を決めて、自動で予約</div>' + cols)
    # 4: サイトの更新は、もう面倒でやりたくなかった（前の手作業に線を引く）
    t4, s4 = T(4, 0), S(4)
    h += win(t4, T(5, 0), kick("前のサイト")
             + '<div class="card" style="left:120px;top:200px;width:660px;height:200px;font-size:60px;align-items:center;color:#8A93A3">サイトの手作業の更新</div>'
             + f'<div class="strike" style="left:100px;width:700px;top:295px;animation:{a("strike", t4 + s4 - 1.0, .35)}"></div>')
    # 5: 浮いた時間は、ロボットの研究へ
    t5 = T(5, 0)
    h += win(t5, None, kick("浮いた時間は")
             + f'<div class="card" style="left:48px;top:230px;width:300px;height:200px;font-size:46px;opacity:0;animation:{a("up", t5 + 0.2)}"><small>いままでの</small>30〜45分</div>'
             + f'<div class="arrowR" style="left:372px;top:314px;opacity:0;animation:{a("show", t5 + 0.6, .2)}"></div>'
             + f'<div class="card or" style="left:420px;top:230px;width:432px;height:200px;font-size:50px;white-space:nowrap;opacity:0;animation:{a("pop", t5 + 0.9, .35)}"><small>これからは</small>ロボットの研究</div>')
    h += outro(T, OUTRO_K)   # 締め：サイトへの入口
    return h


def OVER(T):
    return stage_fx(T, fx=[dict(kind="rays", t=T(5, 0.9), d=3.0)])


FACE = [(-1, 0.0, 1.0, "joy"), (1, 0.0, 3.0, "trouble"), (2, 0.0, 2.5, "joy"), (5, 0.0, 3.0, "joy"), ("end", 0.0, 3.0, "smile")]
POSE = [(-1, 0.0, 1), (1, 2.0, 3), (2, 0.0, 2), (4, 0.0, 2), (5, 0.4, 4)]
LEAN = []
INTRO = "dash"
MOVES = [(1, 1.0, "shake", 1.0), (2, 0.4, "hop", 1.2), (3, 0.5, "tilt", 1.0), (5, 0.2, "jump", 1.0)]
SE = [(-1, 0.0, "se_whoosh", -9), (0, 1.4, "se_boot", -12), (1, 0.3, "se_tick", -12), (1, 0.8, "se_tick", -12), (1, 1.3, "se_tick", -12),
      (2, 0.0, "se_whoosh", -12), (2, 0.4, "se_ding", -11), (3, 0.0, "se_whoosh", -12), (4, 0.0, "se_whoosh", -12),
      (5, 0.9, "se_jaran", -10), (5, 0.9, "se_ding", -9)]
ENDING = dict(kind="jump", at=(5, 0.9))
```

### 05 記事の公開は誰が の書き出しのログ

```text
記事の公開は誰が: 838コマ（撮影 838）
out/記事の公開は誰が_版4.mp4  27.9秒
```

### 06 別のAIに疑わせる（25.7秒）の narration.py

```python
# -*- coding: utf-8 -*-
# 2026-11-07: 最後に締め（サイトへの入口）の文を足した。声は「研究のスキル一覧」の 007 をそのまま使う（エイイチ「うちのサイトへの動線部分がないけど？」）
# 別のAIに疑わせる（ショート専用・舞台型・2026-11-06）。エイイチのセリフは、エイイチがこれまでに話した言葉から（codex に任せると、ただのニュースのようになってしまう／最終判断はエイイチ）
FACES = ("base", "smile", "surprise", "doubt", "think", "trouble", "joy", "wink")
SEGS = [
 ("ask_ei", "base", "AIが書いた台本、間違ってたらどうする？", 0.15),
 ("doubt", "joy", "別のAIに、1文ずつ疑わせます！", 0.1),
 ("rows", "base", "数字の出どころ、言い過ぎ、引用の原文。", 0.15),
 ("news_ei", "base", "でも、codexに任せると、ただのニュースみたいになる。", 0.15),
 ("split", "wink", "だから、codexは事実確認だけ。話の筋は、変えません！", 0.15),
 ("final_ei", "base", "最後に決めるのは、オレ。", 0.5),
 ("outro", "smile", "研究の記録は、毎日サイトで公開中です！", 0.6),
]
ELEVEN_READ = [("GPU", "ジーピーユー"), ("AI", "エーアイ"), ("Claude Code", "クロード コード"), ("Claude", "クロード"), ("GitHub", "ギットハブ"), ("Unity", "ユニティ"), ("SNS", "エスエヌエス"), ("ElevenLabs", "イレブンラボ"), ("codex", "コーデックス"), ("1つ", "ひとつ"), ("105回", "ひゃくごかい"), ("13分", "じゅうさんぷん"), ("30分", "さんじゅっぷん"), ("45分", "よんじゅうごふん"), ("…", "。")]
EI_LINES = [i for i, seg in enumerate(SEGS) if seg[0].endswith("_ei")]
READ = [("「", ""), ("」", "")]


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

### 06 別のAIに疑わせる の spec.py

```python
# -*- coding: utf-8 -*-
"""別のAIに疑わせる（ショート専用・舞台型・2026-11-06）
エイイチの言葉: codex に任せると、ただのニュースのようになってしまう（2026-10-28）／最終判断はエイイチ（MASTER）"""
from butai_parts import *
OUTRO_K = 6   # 締めの文の番号（narration.py の最後に足した文）

NAME, VER = "別のAIに疑わせる", 4
HEAD = "AIの間違い<br>誰が見つける？"
T_ON = (0, 1.3)


def script_card(left, top, w, t, label="台本", n=5):
    ln = "".join(f'<div class="line" style="left:{left + 30}px;top:{top + 70 + k * 44}px;width:{ww}px;transform-origin:left;transform:scaleX(0);animation:{a("grow", t + 0.12 * k, .3)}"></div>'
                 for k, ww in enumerate([w - 90, w - 140, w - 70, w - 160, w - 110][:n]))
    return f'<div class="card" style="left:{left}px;top:{top}px;width:{w}px;height:{90 + n * 44}px;justify-content:flex-start;padding-top:20px"><small>{label}</small></div>' + ln


def scene(T, S):
    h = CSS
    # 0: AI が書いた台本
    h += win(T(0, 1.3), T(1, 0), kick("AIが書いた台本") + script_card(250, 130, 400, T(0, 1.35)))
    # 1: 別のAIに、1文ずつ疑わせる（右に疑う役。行を順に照らす）
    t1, s1 = T(1, 0), S(1)
    scan = "".join(f'<div style="position:absolute;left:62px;top:{186 + k * 44}px;width:330px;height:30px;border-radius:6px;background:{ORANGE};opacity:0;'
                   f'animation:{a("show", t1 + 0.6 + k * 0.4, .1)}, {a("hide", t1 + 0.95 + k * 0.4, .1)}"></div>' for k in range(5))
    h += win(t1, T(2, 0), kick("1文ずつ") + scan + script_card(48, 130, 380, t1 - 1, "Claude が書く")
             + f'<div class="card or" style="left:480px;top:200px;width:372px;height:200px;font-size:46px;opacity:0;animation:{a("pop", t1 + 0.3, .35)}"><small>別のAIが</small>疑う</div>')
    # 2: 数字の出どころ・言い過ぎ・引用の原文
    t2 = T(2, 0)
    rows = [("ok", "数字の出どころ"), ("ng", "言い過ぎ"), ("ok", "引用の原文")]
    h += win(t2, T(3, 0), kick("見るところ") + "".join(
        f'<div class="chk{" ng" if c == "ng" else ""}" style="top:{150 + k * 110}px;height:90px;font-size:42px;animation:{a("up", t2 + 0.3 + k * 0.7)}"><i>{"✕" if c == "ng" else "✓"}</i>{tx}</div>'
        for k, (c, tx) in enumerate(rows)))
    # 3: codex に任せると、ただのニュースみたいになる
    t3, s3 = T(3, 0), S(3)
    news = ("〇〇社は、新しいロボットを発表しました。<br>価格は未定で、来年の発売を目指すとしています。<br>専門家は、今後の普及に期待を寄せています。")
    h += win(t3, T(4, 0), kick("codex に台本を任せると")
             + f'<div class="card" style="left:48px;top:130px;width:804px;height:280px;font-size:32px;font-weight:700;line-height:1.7;color:{DIM}">{news}</div>'
             + f'<div class="stamp" style="left:420px;top:410px;font-size:52px;animation:{a("slam", t3 + s3 - 1.2)}">ただのニュース</div>')
    # 4: codex は事実確認だけ。話の筋は変えない
    t4, s4 = T(4, 0), S(4)
    h += win(t4, T(5, 0), kick("だから")
             + f'<div class="card or" style="left:48px;top:150px;width:390px;height:300px;font-size:52px;opacity:0;animation:{a("up", t4 + 0.4)}"><small>codex に任せる</small>事実確認</div>'
             + f'<div class="card" style="left:462px;top:150px;width:390px;height:300px;font-size:52px;opacity:0;animation:{a("up", t4 + s4 - 2.2)}"><small>変えない</small>話の筋</div>')
    # 5: 最後に決めるのは、オレ
    t5 = T(5, 0)
    h += win(t5, None, kick("最後に決めるのは")
             + f'<div class="stamp" style="left:0;right:0;margin:auto;width:520px;text-align:center;top:200px;font-size:110px;padding:20px 0;animation:{a("slam", t5 + 0.4)}">エイイチ</div>')
    h += outro(T, OUTRO_K)   # 締め：サイトへの入口
    return h


def OVER(T):
    return stage_fx(T, fx=[dict(kind="spot", t=T(5, 0.2), d=3.0, x=690)])


FACE = [(-1, 0.0, 1.0, "smile"), (1, 0.0, 2.6, "joy"), (2, 0.0, 3.4, "think"), (3, 0.0, 3.6, "trouble"), (4, 0.0, 4.6, "wink"), ("end", 0.0, 3.0, "smile")]
POSE = [(-1, 0.0, 1), (1, 0.0, 2), (3, 0.0, 1), (4, 0.0, 2), (5, 0.3, 4)]
LEAN = []
INTRO = "spin"
MOVES = [(1, 0.3, "hop", 1.0), (2, 0.4, "tilt", 1.2), (3, 2.4, "back", 1.0), (5, 0.4, "spin", 1.0)]
SE = [(-1, 0.0, "se_spin", -10), (0, 1.3, "se_boot", -12), (1, 0.3, "se_pop", -10)] + [(1, 0.6 + k * 0.4, "se_tick", -14) for k in range(5)] + [
      (2, 0.0, "se_whoosh", -12), (2, 0.3, "se_ding", -13), (2, 1.0, "se_buzz", -12), (2, 1.7, "se_ding", -13),
      (3, 0.0, "se_whoosh", -12), (3, 2.46, "se_pechin", -9), (4, 0.0, "se_whoosh", -12), (5, 0.4, "se_pechin", -6), (5, 0.4, "se_jaran", -11)]
ENDING = dict(kind="jump", at=(5, 0.4))
```

### 06 別のAIに疑わせる の書き出しのログ

```text
別のAIに疑わせる: 772コマ（撮影 772）
out/別のAIに疑わせる_版4.mp4  25.7秒
```

### 07 並行で作る（23.4秒）の narration.py

```python
# -*- coding: utf-8 -*-
# 2026-11-07: 最後に締め（サイトへの入口）の文を足した。声は「研究のスキル一覧」の 007 をそのまま使う（エイイチ「うちのサイトへの動線部分がないけど？」）
# 並行で作る（ショート専用・舞台型・2026-11-06）。エイイチのセリフは、エイイチがこれまでに話した言葉から（何本も並行して作ることが多くなったので一覧で見て進めたい／ハコブくんの声のお知らせは会議中に喋られると困る・うるさくない程度に）
FACES = ("base", "smile", "surprise", "doubt", "think", "trouble", "joy", "wink")
SEGS = [
 ("ask_ei", "base", "何本も同時に作ってると、どれがどこまでか分からない。", 0.15),
 ("page", "joy", "進み具合のページに、会話ごとに書き込みます！", 0.1),
 ("turn", "base", "いま誰の番か、ひと目で分かります。", 0.15),
 ("voice", "smile", "エイイチさんの番になったら、僕の声でお知らせ！", 0.15),
 ("mtg_ei", "base", "会議中に喋られると困る。", 0.2),
 ("quiet", "trouble", "はい。うるさくならないように、控えめにします…", 0.5),
 ("outro", "smile", "研究の記録は、毎日サイトで公開中です！", 0.6),
]
ELEVEN_READ = [("GPU", "ジーピーユー"), ("AI", "エーアイ"), ("Claude Code", "クロード コード"), ("Claude", "クロード"), ("Unity", "ユニティ"), ("Mac", "マック"),
               ("pixelstomotors.com", "ピクセルズトゥモーターズ ドットコム"), ("1件", "いっけん"), ("1つ", "ひとつ"), ("1画面", "ひとがめん"), ("2時間", "にじかん"), ("…", "。")]
EI_LINES = [i for i, seg in enumerate(SEGS) if seg[0].endswith("_ei")]
READ = [("「", ""), ("」", "")]


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

### 07 並行で作る の spec.py

```python
# -*- coding: utf-8 -*-
"""並行で作る（ショート専用・舞台型・2026-11-06）
エイイチの言葉: 動画を何本も並行して作ることが多くなったので、一覧で見て簡単に進めたい（2026-10-15）／ハコブくんの声のお知らせは、うるさくならない程度にし、止められるように（仕事のMTG中に喋られると困る）"""
from butai_parts import *
OUTRO_K = 6   # 締めの文の番号（narration.py の最後に足した文）

NAME, VER = "並行で作る", 3
HEAD = "何本も同時に<br>どこまで進んだ？"
T_ON = (0, 1.3)
VIDS = [("入口の待ちが減らない", "C"), ("AIあるある", "E"), ("研究のスキル一覧", "E"), ("現場で扱えるか", "C"), ("どこまでいじっていい", "C")]


def rows(t_in, step, t_turn=None, scatter=False):
    out = ""
    for k, (nm, who) in enumerate(VIDS):
        if scatter:
            pos = f"left:{[60, 330, 140, 420, 230][k]}px;top:{[110, 160, 270, 330, 420][k]}px;transform:rotate({[-6, 4, -3, 7, -5][k]}deg);width:420px;"
        else:
            pos = f"left:48px;top:{110 + k * 92}px;width:804px;"
        badge = ""
        if t_turn is not None:
            ei = who == "E"
            badge = (f'<div style="position:absolute;right:16px;top:12px;width:190px;height:56px;border-radius:8px;display:flex;align-items:center;justify-content:center;'
                     f'font-size:26px;font-weight:900;background:{FACE_2};color:{DIM};{"animation:" + a("on", t_turn + 0.2 * k, .25) if ei else ""}">'
                     f'{"エイイチの番" if ei else "Claude の番"}</div>')
        out += (f'<div class="card" style="{pos}height:80px;font-size:32px;justify-content:center;opacity:0;animation:{a("up", t_in + k * step)}">{nm}{badge}</div>')
    return out


def speaker(left, top, on=True):
    col = ORANGE if on else "#5C6678"
    return (f'<div style="position:absolute;left:{left}px;top:{top + 40}px;width:60px;height:80px;border-radius:8px;background:{col}"></div>'
            f'<div style="position:absolute;left:{left + 40}px;top:{top}px;width:0;height:0;border-top:80px solid transparent;border-bottom:80px solid transparent;border-right:90px solid {col}"></div>')


def scene(T, S):
    h = CSS
    # 0: どれがどこまでか分からない（カードがばらばら）
    h += win(T(0, 1.3), T(1, 0), kick("同時に作っている動画") + rows(T(0, 1.35), 0.15, scatter=True))
    # 1: 進み具合のページに会話ごとに書き込む（そろった一覧）
    t1 = T(1, 0)
    h += win(t1, T(2, 0), kick("進み具合のページ") + rows(t1 + 0.2, 0.18))
    # 2: いま誰の番か、ひと目で分かる
    t2 = T(2, 0)
    h += win(t2, T(3, 0), kick("いま誰の番？") + rows(t2 - 5, 0, t_turn=t2 + 0.3))
    # 3: エイイチさんの番になったら、僕の声でお知らせ
    t3 = T(3, 0)
    waves = "".join(f'<div style="position:absolute;left:{360 + k * 70}px;top:{300 - k * 40}px;width:{60 + k * 40}px;height:{140 + k * 80}px;border:12px solid {ORANGE};'
                    f'border-left:none;border-top-color:transparent;border-bottom-color:transparent;border-radius:0 50% 50% 0;opacity:0;animation:wv2 1s ease-out {t3 + 0.3 + k * 0.2:.3f}s infinite"></div>' for k in range(3))
    h += win(t3, T(4, 0), kick("お知らせ") + "<style>@keyframes wv2 { 0% { opacity:0; } 30% { opacity:1; } 100% { opacity:0; } }</style>"
             + speaker(200, 290) + waves
             + f'<div class="card or" style="left:480px;top:160px;width:370px;height:110px;font-size:36px;white-space:nowrap;align-items:center;opacity:0;animation:{a("pop", t3 + 0.6)}">エイイチの番です</div>')
    # 4: 会議中に喋られると困る
    t4 = T(4, 0)
    h += win(t4, T(5, 0), kick("でも") + speaker(200, 290, on=False)
             + f'<div class="stamp" style="left:420px;top:300px;font-size:70px;animation:{a("slam", t4 + 0.3)}">会議中</div>')
    # 5: うるさくならないように、控えめに（音量が下がる）
    t5, s5 = T(5, 0), S(5)
    h += win(t5, None, kick("音量") + speaker(120, 290, on=True)
             + f'<div class="card" style="left:300px;top:340px;width:552px;height:60px;padding:0"></div>'
             + f'<div style="position:absolute;left:300px;top:340px;width:552px;height:60px;border-radius:12px;background:{ORANGE};transform-origin:left;animation:{a("vol", t5 + 0.8, 1.2)}"></div>'
             + "<style>@keyframes vol { to { transform:scaleX(.3); } }</style>"
             + f'<div class="msg" style="left:300px;top:200px;font-size:50px;opacity:0;animation:{a("up", t5 + 1.6)}">控えめに</div>')
    h += outro(T, OUTRO_K)   # 締め：サイトへの入口
    return h


def OVER(T):
    return stage_fx(T, fx=[dict(kind="shock", t=T(3, 0.3), d=2.2, c="#E2683A")], shakes=[(T(4, 0.3), 0.4)])


FACE = [(-1, 0.0, 1.0, "smile"), (1, 0.0, 3.0, "joy"), (3, 0.0, 2.9, "joy"), (4, 0.0, 2.1, "surprise"), (5, 0.0, 3.3, "trouble"), ("end", 0.0, 3.0, "trouble")]
POSE = [(-1, 0.0, 3), (1, 0.0, 1), (2, 0.0, 2), (4, 0.0, 2), (5, 1.2, 4)]
LEAN = []
INTRO = "spin"
MOVES = [(1, 0.3, "dash", 1.0), (3, 0.2, "hop", 1.4), (4, 0.3, "back", 1.0), (5, 0.2, "bow", 1.6)]
SE = [(-1, 0.0, "se_spin", -10), (0, 1.3, "se_boot", -12), (1, 0.0, "se_whoosh", -12), (2, 0.0, "se_whoosh", -12),
      (2, 0.5, "se_ding", -12), (3, 0.0, "se_whoosh", -12), (3, 0.3, "se_chapter_glock", -10), (4, 0.3, "se_pechin", -8), (5, 0.0, "se_whoosh", -13)]
ENDING = dict(kind="none", at=(5, 2.6))
```

### 07 並行で作る の書き出しのログ

```text
並行で作る: 702コマ（撮影 702）
out/並行で作る_版3.mp4  23.4秒
```

### 08 Claudeのデザイン（24.8秒）の narration.py

```python
# -*- coding: utf-8 -*-
# 2026-11-07: 最後に締め（サイトへの入口）の文を足した。声は「研究のスキル一覧」の 007 をそのまま使う（エイイチ「うちのサイトへの動線部分がないけど？」）
# Claudeのデザイン（ショート専用・舞台型・2026-11-06）。エイイチのセリフは、エイイチがこれまでに話した言葉から（Claude のデザインの質が低いのはデザイン力ではなく進め方・修正・組み立て・統一する力が弱いから／要素を詰め込みすぎる）
FACES = ("base", "smile", "surprise", "doubt", "think", "trouble", "joy", "wink")
SEGS = [
 ("ask_ei", "base", "Claudeにデザインを頼むと、どうなる？", 0.15),
 ("stuff", "trouble", "つい、要素を詰め込みすぎます…", 0.15),
 ("why_ei", "base", "デザイン力じゃない。進め方と、統一する力が弱い。", 0.2),
 ("one", "base", "だから、1画面で伝えることは、1つに決めます。", 0.1),
 ("face", "smile", "線より面。色は、意味を1つだけ。", 0.15),
 ("this", "joy", "このモニターも、そのルールで作り直しました！", 0.5),
 ("outro", "smile", "研究の記録は、毎日サイトで公開中です！", 0.6),
]
ELEVEN_READ = [("GPU", "ジーピーユー"), ("AI", "エーアイ"), ("Claude Code", "クロード コード"), ("Claude", "クロード"), ("Unity", "ユニティ"), ("Mac", "マック"),
               ("pixelstomotors.com", "ピクセルズトゥモーターズ ドットコム"), ("1件", "いっけん"), ("1つ", "ひとつ"), ("1画面", "ひとがめん"), ("2時間", "にじかん"), ("…", "。")]
EI_LINES = [i for i, seg in enumerate(SEGS) if seg[0].endswith("_ei")]
READ = [("「", ""), ("」", "")]


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

### 08 Claudeのデザイン の spec.py

```python
# -*- coding: utf-8 -*-
"""Claudeのデザイン（ショート専用・舞台型・2026-11-06）
エイイチの言葉: Claude のデザインの質が低いのは、デザイン力がないからではなく、進め方・修正・組み立て・統一する力が弱いから／要素を詰め込みすぎる（2026-10-24）
このモニターの見た目も、2026-11-06 にエイイチ「雑に見える」で、面で組む・1画面に1つ・色の意味は1つ、に作り直した"""
from butai_parts import *
OUTRO_K = 6   # 締めの文の番号（narration.py の最後に足した文）

NAME, VER = "Claudeのデザイン", 3
HEAD = "AIの<br>デザイン、どう？"
T_ON = (0, 1.2)


def clutter(t0, step=0.06):
    import random
    random.seed(4)
    cols = ["#E2683A", "#57B5F8", "#3BB273", "#F2C14E", "#C4472A", "#9B6BD6", "#F5F3EE"]
    words = ["NEW!", "★", "重要", "→", "POINT", "!!", "※", "100%", "▶", "✓", "注目", "◆"]
    out = ""
    for k in range(26):
        x, y = random.randint(20, 760), random.randint(40, 520)
        c = random.choice(cols)
        if k % 3 == 0:
            el = f'width:{random.randint(60, 180)}px;height:{random.randint(30, 90)}px;border:3px solid {c};border-radius:{random.choice([0, 8, 40])}px'
            txt = ""
        else:
            el = f'font-size:{random.randint(22, 48)}px;font-weight:900;color:{c}'
            txt = random.choice(words)
        out += (f'<div style="position:absolute;left:{x}px;top:{y}px;{el};transform:rotate({random.randint(-14, 14)}deg);opacity:0;'
                f'animation:{a("pop", t0 + k * step, .2)}">{txt}</div>')
    return out


def scene(T, S):
    h = CSS
    # 0: デザインを頼むと？（白紙）
    h += win(T(0, 1.2), T(1, 0), kick("Claude にデザインを頼むと")
             + f'<div class="card" style="left:200px;top:140px;width:500px;height:340px;align-items:center;font-size:40px;color:{DIM}">（白紙）</div>')
    # 1: 詰め込みすぎる（要素が次々に増える）
    t1 = T(1, 0)
    h += win(t1, T(2, 0), clutter(t1 + 0.15))
    # 2: デザイン力じゃない。進め方と統一する力（エイイチの言葉）
    t2, s2 = T(2, 0), S(2)
    h += win(t2, T(3, 0), kick("エイイチの見立て")
             + f'<div class="card" style="left:48px;top:150px;width:390px;height:300px;font-size:44px;color:#8A93A3;opacity:0;animation:{a("up", t2 + 0.3)}"><small>弱いのは</small>デザイン力</div>'
             + f'<div class="strike" style="left:70px;width:340px;top:330px;animation:{a("strike", t2 + 1.4, .3)}"></div>'
             + f'<div class="card or" style="left:462px;top:150px;width:390px;height:300px;font-size:44px;opacity:0;animation:{a("up", t2 + s2 - 2.2)}"><small>弱いのは</small>進め方と<br>統一する力</div>')
    # 3: 1画面で伝えることは1つ（散らかりが消えて、1文だけ残る）
    t3 = T(3, 0)
    h += win(t3, T(4, 0), kick("だから")
             + f'<div class="msg" style="top:200px;text-align:center;font-size:90px;opacity:0;animation:{a("pop", t3 + 0.6, .4)}">1画面に、1つ</div>')
    # 4: 線より面。色は意味を1つだけ
    t4, s4 = T(4, 0), S(4)
    lines_ = "".join(f'<div style="position:absolute;left:{70 + k * 120}px;top:240px;width:100px;height:160px;border:2px solid #5C6678;border-radius:4px"></div>' for k in range(3))
    faces_ = "".join(f'<div style="position:absolute;left:{480 + k * 125}px;top:240px;width:110px;height:160px;border-radius:12px;background:{ORANGE if k == 1 else FACE_2};'
                     f'opacity:0;animation:{a("up", t4 + 1.0 + k * 0.15)}"></div>' for k in range(3))
    h += win(t4, T(5, 0), kick("決まり")
             + f'<div style="position:absolute;left:70px;top:150px;font-size:40px;font-weight:900;color:{DIM}">線</div>' + lines_
             + f'<div style="position:absolute;left:480px;top:150px;font-size:40px;font-weight:900;color:{INK}">面</div>' + faces_
             + f'<div style="position:absolute;left:480px;top:430px;font-size:30px;font-weight:800;color:{INK};opacity:0;animation:{a("up", t4 + s4 - 1.6)}">色の意味は、1つだけ</div>')
    # 5: このモニターも作り直した（前と後）
    t5 = T(5, 0)
    h += win(t5, None, kick("このモニターも")
             + f'<div class="card" style="left:48px;top:140px;width:380px;height:330px;padding:0;overflow:hidden;opacity:.6"><div style="position:relative;transform:scale(.42);transform-origin:0 0;width:900px;height:780px">{clutter(-10, 0)}</div></div>'
             + f'<div style="position:absolute;left:48px;top:490px;font-size:28px;font-weight:800;color:{DIM}">前</div>'
             + f'<div class="card" style="left:472px;top:140px;width:380px;height:330px;justify-content:flex-start;padding-top:40px;opacity:0;animation:{a("pop", t5 + 0.5, .35)}">'
               f'<span style="font-size:22px;color:{DIM}">■ いまは</span><span style="font-size:40px;margin-top:10px">1画面に、1つ</span>'
               f'<div style="margin-top:30px;height:70px;width:140px;border-radius:10px;background:{ORANGE}"></div></div>'
             + f'<div style="position:absolute;left:472px;top:490px;font-size:28px;font-weight:800;color:{ORANGE};opacity:0;animation:{a("up", t5 + 0.5)}">後</div>')
    h += outro(T, OUTRO_K)   # 締め：サイトへの入口
    return h


def OVER(T):
    return stage_fx(T, shakes=[(T(1, 1.4), 0.6)], fx=[dict(kind="shine", t=T(5, 0.9), d=0.9), dict(kind="shine", t=T(3, 0.6), d=0.8)])


FACE = [(-1, 0.0, 1.0, "smile"), (1, 0.0, 2.3, "trouble"), (2, 0.0, 4.6, "trouble"), (3, 0.0, 3.4, "think"), (4, 0.0, 3.4, "smile"), (5, 0.0, 3.0, "joy"), ("end", 0.0, 3.0, "joy")]
POSE = [(-1, 0.0, 1), (1, 0.6, 3), (2, 0.0, 2), (3, 0.0, 1), (5, 0.5, 4)]
LEAN = []
INTRO = "hop"
MOVES = [(1, 0.2, "shake", 1.4), (2, 0.4, "bow", 1.6), (3, 0.4, "tilt", 1.0), (5, 0.3, "jump", 1.0)]
SE = [(-1, 0.0, "se_rise", -10), (0, 1.2, "se_boot", -12)] + [(1, 0.15 + k * 0.18, "se_pop", -15) for k in range(8)] + [
      (2, 0.0, "se_whoosh", -12), (2, 1.4, "se_pechin", -10), (3, 0.0, "se_whoosh", -12), (3, 0.6, "se_ding", -10),
      (4, 0.0, "se_whoosh", -12), (5, 0.0, "se_whoosh", -12), (5, 0.9, "se_chapter_glock", -10)]
ENDING = dict(kind="jump", at=(5, 0.9))
```

### 08 Claudeのデザイン の書き出しのログ

```text
Claudeのデザイン: 745コマ（撮影 745）
out/Claudeのデザイン_版3.mp4  24.8秒
```

### 09 全部ログに残す（21.9秒）の narration.py

```python
# -*- coding: utf-8 -*-
# 全部ログに残す（ショート専用・舞台型・2026-11-06）。エイイチのセリフは、エイイチがこれまでに話した言葉から（最初はブログ記事のつもりで書いていた／全記録を残すことでAIに読み込ませてそのまま使える／AIにしっかり読まれていることは確認できた）
FACES = ("base", "smile", "surprise", "doubt", "think", "trouble", "joy", "wink")
SEGS = [
 ("log_ei", "base", "毎日の作業、全部ログに残してる。", 0.15),
 ("blog", "smile", "最初は、ブログ記事のつもりでしたよね。", 0.1),
 ("full", "base", "途中から、全部を書き起こすログに変えました。", 0.15),
 ("ai_ei", "base", "全部残せば、AIに読ませて、そのまま使える。", 0.2),
 ("read", "joy", "実際に、サイトはAIにしっかり読まれています！", 0.15),
 ("site", "smile", "記録は、pixelstomotors.comで、毎日公開中です！", 0.5),
]
ELEVEN_READ = [("GPU", "ジーピーユー"), ("AI", "エーアイ"), ("Claude Code", "クロード コード"), ("Claude", "クロード"), ("Unity", "ユニティ"), ("Mac", "マック"),
               ("pixelstomotors.com", "ピクセルズトゥモーターズ ドットコム"), ("1件", "いっけん"), ("1つ", "ひとつ"), ("1画面", "ひとがめん"), ("2時間", "にじかん"), ("…", "。")]
EI_LINES = [i for i, seg in enumerate(SEGS) if seg[0].endswith("_ei")]
READ = [("「", ""), ("」", "")]


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

### 09 全部ログに残す の spec.py

```python
# -*- coding: utf-8 -*-
"""全部ログに残す（ショート専用・舞台型・2026-11-06）
エイイチの言葉: 記事は最初ブログ記事のつもりで書いていた。途中から全量ログに（overview）／全記録を残すことで、AIに読み込ませてそのまま使える（2026-10-20）／AIにしっかり読まれていることは確認できた（2026-11-05）"""
from butai_parts import *

NAME, VER = "全部ログに残す", 2
HEAD = "毎日の作業<br>なぜ全部残す？"
T_ON = (0, 1.2)


def doc(left, top, w, n, t, label, step=0.08, hl=False):
    ln = "".join(f'<div class="line" style="left:{left + 28}px;top:{top + 62 + k * 30}px;height:12px;width:{w - 56 - (k * 37) % 90}px;transform-origin:left;transform:scaleX(0);'
                 f'animation:{a("grow", t + k * step, .25)}"></div>' for k in range(n))
    return f'<div class="card" style="left:{left}px;top:{top}px;width:{w}px;height:{80 + n * 30}px;justify-content:flex-start;padding-top:18px"><small>{label}</small></div>' + ln


def scene(T, S):
    h = CSS
    # 0: 毎日の作業、全部ログに（記録が積み重なる）
    t0 = T(0, 1.2)
    stack = "".join(f'<div class="card" style="left:{230 + k * 18}px;top:{380 - k * 34}px;width:420px;height:120px;font-size:30px;justify-content:center;opacity:0;'
                    f'animation:{a("up", t0 + k * 0.18, .25)}{";background:" + ORANGE + ";color:#fff" if k == 7 else ""}">{"LOG" if k == 7 else ""}</div>' for k in range(8))
    h += win(t0, T(1, 0), kick("毎日の作業") + stack)
    # 1: 最初はブログ記事のつもり（短い記事）
    t1 = T(1, 0)
    h += win(t1, T(2, 0), kick("最初は") + doc(250, 140, 400, 5, t1 + 0.3, "ブログ記事"))
    # 2: 途中から全部を書き起こすログに（長くなる）
    t2, s2 = T(2, 0), S(2)
    h += win(t2, T(3, 0), kick("途中から") + doc(48, 120, 330, 5, t2 - 5, "ブログ記事")
             + f'<div class="arrowR" style="left:400px;top:250px"></div>'
             + doc(450, 40, 402, 16, t2 + 0.4, "全部のログ", step=(s2 - 0.8) / 16))
    # 3: 全部残せば、AIに読ませて、そのまま使える（行を順に読んでいく）
    t3, s3 = T(3, 0), S(3)
    scan = "<style>@keyframes rd { from { top:100px; } to { top:520px; } }</style>" + \
        f'<div style="position:absolute;left:236px;width:428px;height:34px;border-radius:6px;background:{ORANGE};opacity:.55;top:100px;animation:rd {s3:.2f}s linear {t3 + 0.2:.3f}s forwards"></div>'
    h += win(t3, T(4, 0), kick("全部残せば") + doc(250, 40, 400, 16, t3 - 5, "全部のログ") + scan
             + f'<div class="card or" style="left:690px;top:240px;width:160px;height:110px;font-size:46px;align-items:center;opacity:0;animation:{a("pop", t3 + 0.4)}">AI</div>')
    # 4: サイトは AI にしっかり読まれている（AI がサイトに来る）
    t4 = T(4, 0)
    ai = "".join(f'<div class="card" style="left:48px;top:{140 + k * 120}px;width:200px;height:96px;font-size:34px;align-items:center;opacity:0;animation:{a("inR", t4 + 0.3 + k * 0.3)}">AI</div>'
                 f'<div style="position:absolute;left:262px;top:{184 + k * 120}px;height:10px;width:250px;border-radius:5px;background:{ORANGE};transform-origin:left;transform:scaleX(0);animation:{a("grow", t4 + 0.6 + k * 0.3, .4)}"></div>'
                 for k in range(3))
    h += win(t4, T(5, 0), kick("いま") + ai
             + f'<div class="card" style="left:530px;top:180px;width:322px;height:240px;font-size:40px;align-items:center"><small>読まれている</small>サイト</div>')
    # 5: 記録は pixelstomotors.com で毎日公開
    t5 = T(5, 0)
    h += win(t5, None,
             f'<div class="msg" style="top:220px;text-align:center;font-size:74px;opacity:0;animation:{a("pop", t5 + 0.3, .4)}">pixelstomotors<span style="color:{ORANGE}">.com</span></div>'
             f'<div class="msg" style="top:340px;text-align:center;font-size:36px;color:{DIM};opacity:0;animation:{a("up", t5 + 1.0)}">研究の記録を、毎日公開</div>')
    return h


def OVER(T):
    return stage_fx(T, fx=[dict(kind="rise", t=T(3, 0.3), d=T(5, 0.0) - T(3, 0.3)), dict(kind="rays", t=T(5, 0.3), d=3.4)])


FACE = [(-1, 0.0, 1.0, "smile"), (1, 0.0, 2.6, "smile"), (2, 0.0, 3.0, "base"), (4, 0.0, 3.4, "joy"), (5, 0.0, 4.2, "smile"), ("end", 0.0, 3.0, "joy")]
POSE = [(-1, 0.0, 2), (1, 0.0, 1), (3, 0.0, 2), (4, 0.4, 3), (5, 0.3, 4)]
LEAN = []
INTRO = "dash"
MOVES = [(1, 0.3, "turn", 1.4), (2, 0.4, "hop", 1.2), (4, 0.4, "jump", 1.0), (5, 0.4, "bow", 1.2)]
SE = [(-1, 0.0, "se_whoosh", -9), (0, 1.2, "se_boot", -12)] + [(0, 1.2 + k * 0.18, "se_tick", -16) for k in range(8)] + [
      (1, 0.0, "se_whoosh", -12), (2, 0.0, "se_whoosh", -12), (3, 0.0, "se_whoosh", -12), (3, 0.4, "se_pop", -10),
      (4, 0.0, "se_whoosh", -12), (4, 0.6, "se_ding", -12), (5, 0.3, "se_chapter_glock", -10)]
ENDING = dict(kind="jump", at=(5, 0.3))
```

### 09 全部ログに残す の書き出しのログ

```text
全部ログに残す: 658コマ（撮影 658）
out/全部ログに残す_版2.mp4  21.9秒
```

### 10 このショートの作り方（23.0秒）の narration.py

```python
# -*- coding: utf-8 -*-
# 2026-11-07: 最後に締め（サイトへの入口）の文を足した。声は「研究のスキル一覧」の 007 をそのまま使う（エイイチ「うちのサイトへの動線部分がないけど？」）
# このショートの作り方（ショート専用・舞台型・2026-11-06）。エイイチのセリフは、エイイチがこれまでに話した言葉から（動画づくりで一番時間を取られるのは音声確認）
FACES = ("base", "smile", "surprise", "doubt", "think", "trouble", "joy", "wink")
SEGS = [
 ("ask_ei", "base", "このショート、どうやって作ってる？", 0.15),
 ("script", "joy", "台本と画面は、僕、Claudeが作りました！", 0.1),
 ("room", "smile", "この部屋は、Claude Codeが、Unityで建てました。", 0.1),
 ("voice", "base", "声はElevenLabs。僕の声も、AIです。", 0.15),
 ("check_ei", "base", "いちばん時間がかかるのは、音声の確認。", 0.2),
 ("decide", "joy", "見て決めるのは、エイイチさん！", 0.5),
 ("outro", "smile", "研究の記録は、毎日サイトで公開中です！", 0.6),
]
ELEVEN_READ = [("GPU", "ジーピーユー"), ("AI", "エーアイ"), ("Claude Code", "クロード コード"), ("Claude", "クロード"), ("GitHub", "ギットハブ"), ("Unity", "ユニティ"), ("SNS", "エスエヌエス"), ("ElevenLabs", "イレブンラボ"), ("codex", "コーデックス"), ("1つ", "ひとつ"), ("105回", "ひゃくごかい"), ("13分", "じゅうさんぷん"), ("30分", "さんじゅっぷん"), ("45分", "よんじゅうごふん"), ("…", "。")]
EI_LINES = [i for i, seg in enumerate(SEGS) if seg[0].endswith("_ei")]
READ = [("「", ""), ("」", "")]


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

### 10 このショートの作り方 の spec.py

```python
# -*- coding: utf-8 -*-
"""このショートの作り方（ショート専用・舞台型・2026-11-06）
エイイチの言葉: 動画づくりで一番時間を取られるのは音声確認（2026-11-02）。部屋は依頼箱 #99 で Claude Code が Unity で建てた（22:46 開始 → 22:59 完了＝13分）"""
from butai_parts import *
OUTRO_K = 6   # 締めの文の番号（narration.py の最後に足した文）

NAME, VER = "このショートの作り方", 3
HEAD = "この動画<br>誰が作った？"
T_ON = (0, 1.0)
STEPS = ["台本", "画面", "部屋", "声", "決める"]


def pipe(done, now=(), t=None):
    """下の段に5つの工程。done はもうオレンジ、now は t で灯す"""
    out = ""
    for k, nm in enumerate(STEPS):
        st = f"background:{ORANGE};color:#fff;" if k in done else ""
        an = f"animation:{a('on', t, .25)};" if (k in now and t is not None) else ""
        out += (f'<div class="card" style="left:{48 + k * 164}px;top:450px;width:148px;height:96px;font-size:32px;align-items:center;padding:0;{st}{an}">{nm}</div>')
    return out


def wave(t0, left=48, top=150, w=804, h=220, n=36, dim=False):
    import random
    random.seed(3)
    bw = w / n
    out = "<style>@keyframes wv { 0%,100% { transform:scaleY(.25); } 50% { transform:scaleY(1); } }</style>"
    for k in range(n):
        d = random.uniform(.35, .8)
        out += (f'<div style="position:absolute;left:{left + k * bw + 3:.0f}px;top:{top}px;width:{bw - 6:.0f}px;height:{h}px;border-radius:6px;'
                f'background:{FACE_2 if dim else ORANGE};transform:scaleY(.25);animation:wv {d:.2f}s ease-in-out {t0 + random.uniform(0, .4):.3f}s infinite"></div>')
    return out


def scene(T, S):
    h = CSS
    # 0: どうやって作ってる？
    h += win(T(0, 1.0), T(1, 0), f'<div class="qm" style="top:20px;font-size:380px;animation:breathe .9s ease-in-out {T(0, 1.05):.3f}s 2">?</div>')
    # 1: 台本と画面は Claude
    t1 = T(1, 0)
    h += win(t1, T(2, 0), kick("台本と画面") + '<div class="msg" style="font-size:96px;top:150px">Claude</div>' + pipe([], [0, 1], t1 + 0.6))
    # 2: この部屋は Claude Code が Unity で建てた（13分）
    t2 = T(2, 0)
    h += win(t2, T(3, 0), kick("この部屋（Claude Code × Unity）")
             + f'<div class="hero" style="left:48px;top:130px;font-size:240px"><b id="m13" style="font-weight:900">0</b><span>分で建った</span></div>'
             + counter_js("m13", t2 + 0.4, t2 + 2.4, 13, 0) + pipe([0, 1], [2], t2 + 0.6))
    # 3: 声は ElevenLabs（波形が動く）
    t3 = T(3, 0)
    h += win(t3, T(4, 0), kick("声は ElevenLabs") + wave(t3 + 0.2, top=130, h=260) + pipe([0, 1, 2], [3], t3 + 0.5))
    # 4: いちばん時間がかかるのは、音声の確認（波形の上を再生ヘッドが何度も往復）
    t4, s4 = T(4, 0), S(4)
    h += win(t4, T(5, 0), kick("いちばん時間がかかる") + wave(t4, top=130, h=200, dim=True)
             + "<style>@keyframes ph { 0% { left:48px; } 100% { left:844px; } }</style>"
             + f'<div style="position:absolute;top:110px;width:8px;height:240px;border-radius:4px;background:{INK};left:48px;animation:ph .9s linear {t4 + 0.3:.3f}s infinite"></div>'
             + f'<div class="stamp" style="left:250px;top:330px;font-size:60px;animation:{a("slam", t4 + s4 - 1.4)}">音声の確認</div>')
    # 5: 見て決めるのは、エイイチさん
    t5 = T(5, 0)
    h += win(t5, None, kick("見て決めるのは")
             + f'<div class="msg" style="font-size:110px;top:150px;opacity:0;animation:{a("pop", t5 + 0.3, .35)}">エイイチ</div>' + pipe([0, 1, 2, 3], [4], t5 + 0.3))
    h += outro(T, OUTRO_K)   # 締め：サイトへの入口
    return h


def OVER(T):
    return stage_fx(T, bursts=[(T(5, 0.3), 200)])


FACE = [(-1, 0.0, 1.0, "joy"), (1, 0.0, 3.3, "joy"), (2, 0.0, 3.5, "smile"), (3, 0.0, 3.6, "wink"), (4, 0.0, 3.0, "trouble"), ("end", 0.0, 3.0, "joy")]
POSE = [(-1, 0.0, 1), (2, 1.5, 3), (3, 0.0, 2), (4, 0.0, 2), (5, 0.3, 4)]
LEAN = []
INTRO = "hop"
MOVES = [(1, 0.2, "jump", 1.0), (2, 0.5, "turn", 1.6), (3, 0.3, "tilt", 1.2), (5, 0.3, "bow", 1.2)]
SE = [(-1, 0.0, "se_rise", -9), (0, 1.0, "se_boot", -12), (1, 0.0, "se_whoosh", -12), (1, 0.6, "se_ding", -11),
      (2, 0.0, "se_whoosh", -12), (2, 2.4, "se_ding", -10), (3, 0.0, "se_whoosh", -12), (4, 0.0, "se_whoosh", -12),
      (4, 1.4, "se_pechin", -9), (5, 0.3, "se_jaran", -10), (5, 0.3, "se_ding", -9)]
ENDING = dict(kind="jump", at=(6, 0.9))   # 2026-11-07 跳ねると頭が工程の「声」にかかるので、締め（サイトの入口）で跳ねる
```

### 10 このショートの作り方 の書き出しのログ

```text
このショートの作り方: 691コマ（撮影 691）
out/このショートの作り方_版3.mp4  23.0秒
```


## 作業ログ ― X に動画付きで予約した10本

AIあるある と同じく、YouTube より先に X に動画で出します。AIあるある が11月16日まで毎晩22:00に入っているので、その枠を引き継いで11月17日から26日に1本ずつ入れました。動画は Cloudflare R2（p2m-media）に `short_butai{NN}_{名前}_v{N}.mp4` の名前で入れ、投稿文の `x_video` から指しています。いまの決まりどおり、X の文にリンクは入れていません。ショートは LinkedIn に出さない決まりなので、LinkedIn には予約していません。

| 日時 | 予定表の id | 動画 |
|---|---|---|
| 11-17 22:00 | BUTAI01-x | short_butai01_skills_v5.mp4 |
| 11-18 22:00 | BUTAI02-x | short_butai02_tanomikata_v4.mp4 |
| 11-19 22:00 | BUTAI03-x | short_butai03_yoru_v3.mp4 |
| 11-20 22:00 | BUTAI04-x | short_butai04_oboeru_v4.mp4 |
| 11-21 22:00 | BUTAI05-x | short_butai05_kokai_v4.mp4 |
| 11-22 22:00 | BUTAI06-x | short_butai06_utagau_v4.mp4 |
| 11-23 22:00 | BUTAI07-x | short_butai07_heiko_v3.mp4 |
| 11-24 22:00 | BUTAI08-x | short_butai08_design_v3.mp4 |
| 11-25 22:00 | BUTAI09-x | short_butai09_log_v2.mp4 |
| 11-26 22:00 | BUTAI10-x | short_butai10_tsukurikata_v3.mp4 |

### 最初に R2 へ入れたときの出力（11月6日）

```text
入れる 10・入っている 0・名前の重なり 0・合計 32.2MB
  10/10
おわり
```

### X の投稿文（sns/BUTAI{NN}_sns_posts.json・10本の全文）

#### BUTAI01_sns_posts.json

```json
{
  "meta": {
    "kind": "x_short_video",
    "title": "ショート 舞台型 01「研究のスキル一覧」",
    "url": "https://pixelstomotors.com"
  },
  "posts": {
    "x": "ロボットの研究、ひとりで回してる。\n仕組みは7つ。いちばん助かってるのは、Claude Code に自動で依頼を渡す「依頼箱」。\n\n煩わしかったターミナルの作業から、やっと解放された。\n\n#ClaudeCode #フィジカルAI",
    "x_video": "/media/short_butai01_skills_v5.mp4"
  },
  "note": "ショート専用『舞台型』（Claude・Claude Code ネタ）を YouTube より先に X に動画で出す（AIあるある と同じ扱い・22:00 の枠を引き継ぐ）。エイイチのセリフと X の文は、エイイチがこれまでに話した言葉から。作り方は iCloud 動画/_ショート専用/{動画名}/"
}
```

#### BUTAI02_sns_posts.json

```json
{
  "meta": {
    "kind": "x_short_video",
    "title": "ショート 舞台型 02「Claude Codeへの頼み方」",
    "url": "https://pixelstomotors.com"
  },
  "posts": {
    "x": "Claude Code への指示を、自分で貼って渡すのはもうやめた。\nClaude が依頼を書いて箱に置き、Mac の見張り係が1件ずつ渡す。終わったら判定つきで報告が来る。\n\n実機を動かす依頼だけは、確認待ちで止める。\n\n#ClaudeCode #生成AI",
    "x_video": "/media/short_butai02_tanomikata_v4.mp4"
  },
  "note": "ショート専用『舞台型』（Claude・Claude Code ネタ）を YouTube より先に X に動画で出す（AIあるある と同じ扱い・22:00 の枠を引き継ぐ）。エイイチのセリフと X の文は、エイイチがこれまでに話した言葉から。作り方は iCloud 動画/_ショート専用/{動画名}/"
}
```

#### BUTAI03_sns_posts.json

```json
{
  "meta": {
    "kind": "x_short_video",
    "title": "ショート 舞台型 03「寝ている間に回す」",
    "url": "https://pixelstomotors.com"
  },
  "posts": {
    "x": "寝てる間も、研究は進めたい。\n夜は Claude Code が、借りたGPUで学習して片付けまで。Unity の店も、条件を変えて何十回も回す。\n\n途中経過は知りたい。でも毎時間は多い。2時間ごとか区切りで十分。\n\n#ClaudeCode #フィジカルAI",
    "x_video": "/media/short_butai03_yoru_v3.mp4"
  },
  "note": "ショート専用『舞台型』（Claude・Claude Code ネタ）を YouTube より先に X に動画で出す（AIあるある と同じ扱い・22:00 の枠を引き継ぐ）。エイイチのセリフと X の文は、エイイチがこれまでに話した言葉から。作り方は iCloud 動画/_ショート専用/{動画名}/"
}
```

#### BUTAI04_sns_posts.json

```json
{
  "meta": {
    "kind": "x_short_video",
    "title": "ショート 舞台型 04「Claudeに覚えてもらう」",
    "url": "https://pixelstomotors.com"
  },
  "posts": {
    "x": "同じこと、何回言えば覚えるの？\n会話が変わると、AIは決めたことを忘れる。だから決めたことはルールの本に書き足す。動画のルールは、もう105回。\n\nそれでも言う。いい加減、覚えてください。\n\n#Claude #生成AI",
    "x_video": "/media/short_butai04_oboeru_v4.mp4"
  },
  "note": "ショート専用『舞台型』（Claude・Claude Code ネタ）を YouTube より先に X に動画で出す（AIあるある と同じ扱い・22:00 の枠を引き継ぐ）。エイイチのセリフと X の文は、エイイチがこれまでに話した言葉から。作り方は iCloud 動画/_ショート専用/{動画名}/"
}
```

#### BUTAI05_sns_posts.json

```json
{
  "meta": {
    "kind": "x_short_video",
    "title": "ショート 舞台型 05「記事の公開は誰が」",
    "url": "https://pixelstomotors.com"
  },
  "posts": {
    "x": "記事1本の仕上げと公開、前は30〜45分かかってた。\nいまは Claude が仕上げて、GitHub からそのまま公開。SNS も時間を決めて自動で予約。\n\nサイトの更新は、もう面倒でやりたくなかった。\n\n#Claude #ひとり研究所",
    "x_video": "/media/short_butai05_kokai_v4.mp4"
  },
  "note": "ショート専用『舞台型』（Claude・Claude Code ネタ）を YouTube より先に X に動画で出す（AIあるある と同じ扱い・22:00 の枠を引き継ぐ）。エイイチのセリフと X の文は、エイイチがこれまでに話した言葉から。作り方は iCloud 動画/_ショート専用/{動画名}/"
}
```

#### BUTAI06_sns_posts.json

```json
{
  "meta": {
    "kind": "x_short_video",
    "title": "ショート 舞台型 06「別のAIに疑わせる」",
    "url": "https://pixelstomotors.com"
  },
  "posts": {
    "x": "AIが書いた台本は、別のAIに1文ずつ疑わせてる。数字の出どころ、言い過ぎ、引用の原文。\n\nでも codex に台本ごと任せると、ただのニュースみたいになる。だから事実確認だけ。最後に決めるのはオレ。\n\n#生成AI #ファクトチェック",
    "x_video": "/media/short_butai06_utagau_v4.mp4"
  },
  "note": "ショート専用『舞台型』（Claude・Claude Code ネタ）を YouTube より先に X に動画で出す（AIあるある と同じ扱い・22:00 の枠を引き継ぐ）。エイイチのセリフと X の文は、エイイチがこれまでに話した言葉から。作り方は iCloud 動画/_ショート専用/{動画名}/"
}
```

#### BUTAI07_sns_posts.json

```json
{
  "meta": {
    "kind": "x_short_video",
    "title": "ショート 舞台型 07「並行で作る」",
    "url": "https://pixelstomotors.com"
  },
  "posts": {
    "x": "動画を何本も同時に作ってると、どれがどこまでか分からなくなる。\n進み具合のページで、いま誰の番かをひと目で。\n\n自分の番になると、ハコブくんが声で知らせてくれる。会議中だけは困る。\n\n#Claude #ひとり研究所",
    "x_video": "/media/short_butai07_heiko_v3.mp4"
  },
  "note": "ショート専用『舞台型』（Claude・Claude Code ネタ）を YouTube より先に X に動画で出す（AIあるある と同じ扱い・22:00 の枠を引き継ぐ）。エイイチのセリフと X の文は、エイイチがこれまでに話した言葉から。作り方は iCloud 動画/_ショート専用/{動画名}/"
}
```

#### BUTAI08_sns_posts.json

```json
{
  "meta": {
    "kind": "x_short_video",
    "title": "ショート 舞台型 08「Claudeのデザイン」",
    "url": "https://pixelstomotors.com"
  },
  "posts": {
    "x": "Claude にデザインを頼むと、要素を詰め込みすぎる。\n弱いのはデザイン力じゃなくて、進め方と統一する力。\n\nだから1画面に1つ、線より面、色の意味は1つだけ。このショートのモニターも、それで作り直した。\n\n#Claude #デザイン",
    "x_video": "/media/short_butai08_design_v3.mp4"
  },
  "note": "ショート専用『舞台型』（Claude・Claude Code ネタ）を YouTube より先に X に動画で出す（AIあるある と同じ扱い・22:00 の枠を引き継ぐ）。エイイチのセリフと X の文は、エイイチがこれまでに話した言葉から。作り方は iCloud 動画/_ショート専用/{動画名}/"
}
```

#### BUTAI09_sns_posts.json

```json
{
  "meta": {
    "kind": "x_short_video",
    "title": "ショート 舞台型 09「全部ログに残す」",
    "url": "https://pixelstomotors.com"
  },
  "posts": {
    "x": "毎日の作業は、全部ログに残してる。\n最初はブログ記事のつもりだった。途中から、全部を書き起こすログに変えた。\n\n全部残せば、AIに読ませてそのまま使える。実際、サイトはAIにしっかり読まれてる。\n\n#Claude #フィジカルAI",
    "x_video": "/media/short_butai09_log_v2.mp4"
  },
  "note": "ショート専用『舞台型』（Claude・Claude Code ネタ）を YouTube より先に X に動画で出す（AIあるある と同じ扱い・22:00 の枠を引き継ぐ）。エイイチのセリフと X の文は、エイイチがこれまでに話した言葉から。作り方は iCloud 動画/_ショート専用/{動画名}/"
}
```

#### BUTAI10_sns_posts.json

```json
{
  "meta": {
    "kind": "x_short_video",
    "title": "ショート 舞台型 10「このショートの作り方」",
    "url": "https://pixelstomotors.com"
  },
  "posts": {
    "x": "このショートは、台本と画面を Claude、部屋を Claude Code が Unity で13分で建てた。声は ElevenLabs。\n\nいちばん時間がかかるのは、音声の確認。見て決めるのはオレ。\n\n#ClaudeCode #Unity",
    "x_video": "/media/short_butai10_tsukurikata_v3.mp4"
  },
  "note": "ショート専用『舞台型』（Claude・Claude Code ネタ）を YouTube より先に X に動画で出す（AIあるある と同じ扱い・22:00 の枠を引き継ぐ）。エイイチのセリフと X の文は、エイイチがこれまでに話した言葉から。作り方は iCloud 動画/_ショート専用/{動画名}/"
}
```


## 作業ログ ― video_rules.md の12節「舞台型」（いまの全文）

```markdown
## 12. ショート「舞台型」（2026-11-06「研究のスキル一覧」で作った・エイイチ「OK」）

TikTok の縦長動画（「SNS運用、Claudeが終わらせた」）の構図を取り込んだ、ショート専用の型。上に見出し、奥のモニターに1つずつ、2人が床の円の上に立ち、足もとに字幕の黒い箱。中身と舞台はうちのもの（参考動画のろうそく・青緑の円はなぞらない）。
- 舞台は Unity で建てた夜の作業部屋（ServiceSim の `Assets/VideoStage/`。依頼箱 #99。既存のシーンと設定には触れない）。撮ったもの: KIOXIA `videos/_ショート専用/研究のスキル一覧/_取り込み/舞台/`（stage_bg・stage_bg_off・確認用・stage_meta.json〈モニターの4隅・円・カメラの値〉・6秒のループ）。次の回もこの舞台を使い、モニターの中身とセリフだけを替える
- 書き出し: `_ショート専用/舞台型_共通/butai_kit.py`（AIあるある の aiaru_kit.py が元）。作業フォルダに narration.py・spec.py（HEAD・scene(T,S)・OVER(T)・FACE・POSE・MOVES・SE・ENDING・STAGE・BG_NAME・T_ON・FOOT_L/FOOT_R・SIZE・SUB_ONE_LINE）を置き、`P2M_BASE=<_制作ベース> python3 ../舞台型_共通/butai_kit.py [--still 秒,…]`
- 2人の位置: 円の奥寄りに寄せて立たせる（足もと y 1525・左 x 390／右 x 690・SIZE 0.88）。エイイチ「距離感もっと近くて良さそう」で、最初の 580px 離れた位置から詰めた。身長の比はハコブくん 1.15m・エイイチ 1.7m（エイイチのドット絵は整数倍で拡大）
- 字幕: 黒い箱に1行ずつ（48px・1行15字まで）、足もとの下（y 1590）。2行の箱だとハコブくんの体が隠れた。ハコブくんは水色・エイイチは白
- **モニターの中（900×600）は面で組む**（エイイチ「グラフィカルな部分はもう少しクオリティを上げたい。雑に見える」）。細い枠線・記号のアイコン（▶■✉）・緑と赤をやめた。どの画面も、左上に「番号 / 07・名前」、その下に言いたいことを1文、図は1つ。オレンジは「いま話している1つ」だけ（止まり・いまの手順・選んだ1つ・✕）。なくても伝わる注記（凡例など）は入れない
- **床の円はゆっくり回す**（48秒で1周）。Unity の背景から円を消した版（stage_bg_noring.png・OpenCV の inpaint で作った）の上に、stage_meta.json のカメラの値で円を描き直し、破線の明るさは Unity の照明から取った（ring_lut.json）。画面を毎コマ撮るので、書き出しは約4分
- **紙吹雪は細かく華やかに**（エイイチの指示）。円の左右から打ち上げ、230片・小さめ・四角／丸／リボン／光る星、裏返りながら揺れて落ちる（canvas に描く。spec.py の OVER）
- 中身はエイイチが実際に使っているものだけにする（誇張しない）。「いちばん○○」の理由はエイイチの言葉をセリフにする（この回は「今まで煩わしかったターミナルでの作業から解放されたこと」）
- **2本目からの部品**: `舞台型_共通/butai_parts.py`（モニターの CSS・win・kick・stage_fx）と `舞台型_共通/stage/`（舞台の画像・位置の値・円の明るさ）。spec.py は `from butai_parts import *` で始め、scene と OVER と動き・効果音だけを書く。2〜10本目（Claude・Claude Code ネタ）はこの形で作った
- **山場の演出は毎回紙吹雪にしない**（エイイチ「毎回紙吹雪なので違うパターンも考えて」）。stage_fx の fx に1〜2つ選ぶ: rays（床から光の筋が回る・お祝い）／speed（マンガの集中線・叩きつけやツッコミ）／shock（床の円から波紋・知らせや決定）／spot（まわりを暗くしてスポットライト・決めるのは○○）／stars（星がまたたき少し暗く・夜）／shine（モニターの上を光の帯が走る・作り直した）／rise（床から小さな四角が立ちのぼる・データや記録）。紙吹雪（bursts）は5本に1本くらいまで。暗くする演出はモニターの中にはかけない
- エイイチのセリフは、エイイチがこれまでに話した言葉（メモリ・MASTER・ルールの本）から取り、どこから取ったかを narration.py の1行目に書く
- **最後は必ずサイトへの入口で締める**（2026-11-07 エイイチの指摘「うちのサイトへの動線部分がないけど？なんで無くしたの？」。2本目からの8本で入れ忘れていた）。narration.py の最後に「研究の記録は、毎日サイトで公開中！」の文を足し（声は「研究のスキル一覧」の 007 をそのまま使う）、spec.py の scene の最後に `h += outro(T, 締めの文の番号)` を書く（butai_parts.py の outro。モニターを pixelstomotors.com で覆う）。X の投稿文はいまリンクなしなので、動画の中の入口が唯一の動線になる
- **書き出す前に、文字の被りを機械で見る**（2026-11-07 エイイチの指摘「文字が被ってるところがある」。「記事の公開は誰が」で数字「30〜45分」と手作業のカードが重なっていた）。作業フォルダで `butai_kit.py --still 1` を走らせてから `python3 ../舞台型_共通/check_overlap.py 総尺` を回し、0.1秒ごとに (1) モニターの中の文字どうしの重なり (2) エイイチの頭と肩にかかる文字（締めで跳ねる高さも含む）を出す。出たものは静止画で目で確かめ、本当に被っていれば直す。大きな数字の行の高さによる誤検出（字面は離れている）と、わざと散らかした絵（「Claudeのデザイン」の詰め込みの例）は残してよい
```

### video_rules.md の更新履歴（この作業で足した5行）

```markdown
- 2026-11-07（109回目）: C節に「最後は必ずサイトへの入口」を足した。舞台型だけでなく、本編・ショートのすべての型に当てる（エイイチの指示）
- 2026-11-07（108回目）: 12節に「書き出す前に、文字の被りを機械で見る」（check_overlap.py）を足した（エイイチの指摘）。10本を点検し、4本（記事の公開・頼み方・作り方・スキル一覧）の被りを直した
- 2026-11-07（107回目）: 12節に「最後は必ずサイトへの入口で締める」を足した（エイイチの指摘）。舞台型の2本目からの8本に締めがなく、作り直した
- 2026-11-06（106回目）: 12節に、2本目からの部品（butai_parts.py）と、山場の演出を毎回紙吹雪にしない決まり（7つの演出）を足した。舞台型で Claude・Claude Code ネタを9本作った
- 2026-11-06（105回目）: 12節「舞台型」を足した。TikTok の縦長動画の構図（舞台・奥のモニター・床の円・足もとの字幕の箱）を取り込み、Unity で夜の作業部屋の舞台を建てて1本目「研究のスキル一覧」（29.2秒）を作った。2人を寄せる・モニターの中を面で組む・字幕を1行ずつ・床の円を回す・紙吹雪を細かく、はエイイチの指示
```


## 作業ログ ― 公開のあとに直した3つ（サイトへの入口・文字の被り・締めの声）

10本を X に予約し、この記事を公開したあとで、エイイチに動画を見てもらって3つ直しました。直した順に書きます。

### エイイチ「うちのサイトへの動線部分がないけど？なんで無くしたの？」

1本目の「研究のスキル一覧」と9本目の「全部ログに残す」は、最後にモニターへ pixelstomotors.com を出して締めていました。ところが、2本目からの残り8本は、部品 butai_parts.py にまとめて作ったときに、この締めを入れ忘れていました。わざと外したのではなく、部品に締めの場面がなかったので、spec.py ごとに書くのを忘れた、というのが原因です。

X の投稿文には、いまの決まりでリンクを入れていません。動画の中の締めが、見た人がサイトへ来るただ1つの入口です。それがない動画を8本も予約していました。

直し方は次の3つです。

- butai_parts.py に、締めの場面を出す `outro(T, k)` を足した。モニターを紺の面で覆い、「研究の記録」の見出し・`pixelstomotors.com`（`.com` だけオレンジ）・「毎日、公開中」を順に出す
- 8本の narration.py の最後に、ハコブくんの文「研究の記録は、毎日サイトで公開中！」を足した。声は1本目の同じ文の録音（tts/007.wav）を写して使い、新しく作らなかった
- 8本の spec.py の scene の最後に `h += outro(T, OUTRO_K)` を書いた（OUTRO_K は足した文の番号で、どれも6）

あわせて、エイイチの指示「動画の最後にサイトへの動線セクションは常に追加してください」を、舞台型だけでなく本編・ショートのすべての型の決まりとして、video_rules.md のいちばん先に読む C節に書き足しました。

```markdown
### 最後は必ずサイトへの入口（2026-11-07 エイイチの指示「動画の最後にサイトへの動線セクションは常に追加してください」）

- 本編・ショートの**すべての型**で、動画の最後にサイト（pixelstomotors.com）への入口の場面を入れる。画面に `pixelstomotors.com` を大きく出し、声でも「研究の記録は、毎日サイトで公開中」など、サイトに行けることを言う
- 入れ忘れないよう、書き出しの前の確認（0節・7節）で、最後の場面にサイトの入口があるかを必ず見る。舞台型の部品は 12節（butai_parts.py の outro）
```

### エイイチ「文字が被ってるのところがある」

エイイチがスマホで見た画面写真では、「記事の公開は誰が」の2つ目の場面で、大きな数字「30〜45分」の上に、手作業のカード3枚（サムネイル作り・貼り付け・公開）が重なっていました。数字を左に、カードを右に置いたつもりが、数字が横に長く、カードの下にもぐっていました。

ここは、カードを上の段に横並びにし、数字をその下の真ん中に分けて直しました。

```python
    # 2026-11-07 エイイチ「文字が被ってる」: 数字と手作業のカードが重なっていたので、カードを上の段に横並び、数字を下に分けた
    jobs = "".join(f'<div class="card" style="left:{48 + k * 276}px;top:110px;width:252px;height:92px;font-size:30px;align-items:center;padding:0;white-space:nowrap;opacity:0;animation:{a("up", t1 + 0.3 + k * 0.5)}">{nm}</div>'
                   for k, nm in enumerate(["サムネイル作り", "貼り付け", "公開"]))
    h += win(t1, T(2, 0), kick("前は（記事1本）") + jobs
             + f'<div class="hero" style="left:0;right:0;text-align:center;top:290px;font-size:170px;opacity:0;animation:{a("pop", t1 + s1 - 2.2, .4)}">30〜45<span>分</span></div>')
```

1か所見つかったなら、ほかの9本にもあるはずです。目で全コマを見るのは見落とすので、0.1秒ごとに機械で探す点検 check_overlap.py を作りました。見るのは次の2つです。

- モニターの中で、文字どうしが重なっている所。画面が切り替わる一瞬の重なりは数えず、0.4秒以上続くものだけを出す。上に別の画面が重なって隠れている文字は数えない
- モニターの中の文字が、エイイチの頭と肩（体の上 40%）にかかる所。エイイチの絵は、ページではなく書き出しのときに重ねるので、ページの中にはいない。そこで butai_kit.py の `--still` が、0.1秒ごとのエイイチの絵の範囲を out/ei_rects.json に書くようにした。締めで跳ねる高さも入れている

1回目の点検は、頭にかかる文字だけを正しく拾いました。

```text
== Claude Codeへの頼み方 26.73
エイイチの頭: ナシ  17.9〜18.1秒（0.3秒）
被り 1 件
== このショートの作り方 22.87
エイイチの頭: 声  17.3〜17.9秒（0.6秒）
被り 1 件
== 研究のスキル一覧 29.17
エイイチの頭: ?  2.2〜3.5秒（1.4秒）
エイイチの頭: 解放！  24.2〜24.7秒（0.5秒）
被り 2 件
（ほかの7本は 被り 0 件）
```

文字どうしの重なりは、このとき1件も出ませんでした。直す前の「記事の公開は誰が」を写して点検にかけても0件で、点検のほうが間違っていました。原因は、紙吹雪や床の円を描く透明な層（canvas）が画面のいちばん上に乗っていて、「この点でいちばん上に見えている文字はどれか」を調べると、どの点でもその層が返ってきていたことです。どの文字も「見えていない」扱いになり、重なりを数えていませんでした。モニターの中の要素だけを、透明になった覆いを飛ばして上から探すように直すと、直す前の「記事の公開は誰が」で、エイイチが見つけた被りをそのとおりに出しました。

```text
文字どうし: サムネイル作り × 30〜45分  7.3〜9.5秒（2.3秒）
文字どうし: 貼り付け × 30〜45分  7.3〜9.5秒（2.2秒）
文字どうし: 貼り付け × 分  7.3〜9.5秒（2.2秒）
被り 3 件
```

直した点検で10本をかけ直した結果です（この時点で「記事の公開は誰が」は直してあり、「Claude Codeへの頼み方」の「ナシ」は1回目の結果を見て一度動かしたあと）。

```text
== Claude Codeへの頼み方
文字どうし: 自分で貼って渡す × ナシ  17.9〜18.7秒（0.9秒）
被り 1 件
== Claudeに覚えてもらう
文字どうし: 動画のルールを書き足した回数 × 105  13.0〜13.8秒（0.9秒）
被り 1 件
== Claudeのデザイン
文字どうし: ◆ × NEW!  4.3〜5.7秒（1.5秒）
文字どうし: POINT × 重要  5.0〜20.9秒（4.0秒）
被り 2 件
== このショートの作り方
文字どうし: この部屋（Claude Co × 0  6.6〜6.9秒（0.4秒）
文字どうし: この部屋（Claude Co × 13  8.9〜10.1秒（1.3秒）
被り 2 件
== 並行で作る
被り 0 件
== 全部ログに残す
被り 0 件
== 別のAIに疑わせる
被り 0 件
== 寝ている間に回す
被り 0 件
== 研究のスキル一覧
エイイチの頭: ?  2.6〜3.5秒（1.0秒）
文字どうし: 7 × つの仕組み  4.5〜6.1秒（1.7秒）
文字どうし: $ cd ~/Robotic × 解放！  24.2〜25.5秒（1.4秒）
被り 3 件
== 記事の公開は誰が
被り 0 件
```

出たものは、1つずつ静止画で目で確かめて、本当に被っているものだけを直しました。

| 動画 | 出たもの | 目で見て | 直したこと |
|---|---|---|---|
| Claude Codeへの頼み方 | 「ナシ」のハンコが「自分で貼って渡す」に乗る。最初の位置では、エイイチが跳ねたときに頭で隠れていた | 被っている | ハンコをカードの下の真ん中へ（left 330・top 400） |
| このショートの作り方 | エイイチが跳ねると、頭が下の段の「声」にかかる | 被っている | 跳ねるのを、締めのサイトの画面のときに移した（ENDING を (6, 0.9) に） |
| 研究のスキル一覧 | 「解放！」がコマンドの文字に乗り、頭にもかかる | 被っている | 左下へ移し、字を 60px から 44px に |
| 研究のスキル一覧 | 大きな「7」と「つの仕組み」 | 字の下がくっついていた | 「つの仕組み」を 16px から 48px 下げた（点検では行の高さで出続けるが、字面は離れた） |
| 研究のスキル一覧 | 「?」がエイイチの頭にかかる | 字面は頭より上 | そのまま（大きな字の行の高さによる誤検出） |
| Claudeに覚えてもらう | 「105」と上の見出し | 字面は離れている | そのまま（同じく誤検出） |
| このショートの作り方 | 「13」と上の見出し | 字面は離れている | そのまま（同じく誤検出） |
| Claudeのデザイン | 散らかった言葉どうし | わざと散らかした絵 | そのまま（「詰め込みすぎ」を見せる場面） |

点検のコードの全文です。動画の決まりにも「書き出す前に、文字の被りを機械で見る」を足しました（12節。全文は下の「video_rules.md の12節」）。

```python
# -*- coding: utf-8 -*-
"""舞台型ショートの「文字が被っている所」を探す（2026-11-07 エイイチ「文字が被ってるところがある」）
使い方: 作業フォルダで butai_kit.py --still を1回走らせて _block.html を作ってから
  python3 ../舞台型_共通/check_overlap.py 総尺秒
見るもの:
  1. モニターの中で、文字どうしが重なっている所（0.4秒以上続くものだけ。画面の切り替えの一瞬は数えない）
  2. モニターの中の文字が、エイイチの体の上 40%（頭と肩）にかかる所
     （エイイチは絵を後から重ねるのでページの中には無い。butai_kit.py --still が out/ei_rects.json に0.1秒ごとの位置を書く）
"""
import sys, os, json
from playwright.sync_api import sync_playwright

TOTAL = float(sys.argv[1])
EIR = {round(r[0], 1): r[1:] for r in json.load(open("out/ei_rects.json"))}
JS = r"""
(EI) => {
  const scr = document.querySelector('.scr');
  const out = [];
  const els = [...scr.querySelectorAll('*')].filter(e => [...e.childNodes].some(n => n.nodeType === 3 && n.textContent.trim()));
  const sr = scr.getBoundingClientRect();
  const effOp = (q) => { let o = 1; while (q && q !== scr) { const cs = getComputedStyle(q); o *= parseFloat(cs.opacity); if (cs.display === 'none' || cs.visibility === 'hidden') return 0; q = q.parentElement; } return o; };
  els.forEach((e, i) => {
    let op = 1, a = e;
    while (a && a !== scr) { const cs = getComputedStyle(a); op *= parseFloat(cs.opacity); if (cs.display === 'none' || cs.visibility === 'hidden') op = 0; a = a.parentElement; }
    if (op < 0.5) return;
    const rg = document.createRange(); let r = null;
    e.childNodes.forEach(n => { if (n.nodeType === 3 && n.textContent.trim()) { rg.selectNodeContents(n); for (const q of rg.getClientRects()) { r = r ? {l: Math.min(r.l, q.left), t: Math.min(r.t, q.top), r: Math.max(r.r, q.right), b: Math.max(r.b, q.bottom)} : {l: q.left, t: q.top, r: q.right, b: q.bottom}; } } });
    if (!r || r.r - r.l < 2) return;
    r = {l: Math.max(r.l, sr.left), t: Math.max(r.t, sr.top), r: Math.min(r.r, sr.right), b: Math.min(r.b, sr.bottom)};
    if (r.r <= r.l || r.b <= r.t) return;
    // 見えている点があるか（上に別の窓が重なって隠れていないか）
    let vis = 0;
    for (let gx = 1; gx < 8; gx++) for (let gy = 1; gy < 4; gy++) {
      const x = r.l + (r.r - r.l) * gx / 8, y = r.t + (r.b - r.t) * gy / 4;
      // いちばん上に見えているモニターの中の要素（舞台の外の飾りや、透明になった覆いは飛ばす）
      const h = document.elementsFromPoint(x, y).find(q => q !== scr && scr.contains(q) && effOp(q) > 0.05);
      if (h && (h === e || e.contains(h) || h.contains(e))) vis++;
    }
    out.push({id: i, txt: e.textContent.trim().slice(0, 14), r, vis, el: e});
  });
  const res = [];
  for (let i = 0; i < out.length; i++) {
    const A = out[i];
    if (A.vis && A.r.r > EI[0] + 6 && A.r.l < EI[2] - 6 && A.r.b > EI[1] + 6 && A.r.t < EI[3]) res.push(['頭', A.txt, Math.round(A.r.l), Math.round(A.r.t)]);
    for (let j = i + 1; j < out.length; j++) {
      const B = out[j];
      if (A.el.contains(B.el) || B.el.contains(A.el)) continue;
      const w = Math.min(A.r.r, B.r.r) - Math.max(A.r.l, B.r.l), h = Math.min(A.r.b, B.r.b) - Math.max(A.r.t, B.r.t);
      if (w > 4 && h > 4 && A.vis && B.vis) {   // 両方とも見えている所があるときだけ（上の窓で覆われた文字は数えない）
        const s = Math.min((A.r.r - A.r.l) * (A.r.b - A.r.t), (B.r.r - B.r.l) * (B.r.b - B.r.t));
        if (w * h > 0.04 * s) res.push(['重', A.txt + ' × ' + B.txt, Math.round(Math.max(A.r.l, B.r.l)), Math.round(Math.max(A.r.t, B.r.t))]);
      }
    }
  }
  return res;
}
"""
seen = {}
with sync_playwright() as p:
    br = p.chromium.launch(); pg = br.new_page(viewport={"width": 1080, "height": 1920})
    pg.goto("file://" + os.path.abspath("_block.html")); pg.evaluate("document.fonts.ready"); pg.wait_for_timeout(300)
    t = 0.0
    while t < TOTAL:
        pg.evaluate(f"window.setT({t:.2f})")
        for kind, what, x, y in pg.evaluate(JS, EIR.get(round(t, 1), [0, 9999, 0, 0])):
            k = (kind, what)
            seen.setdefault(k, []).append(round(t, 1))
        t += 0.1
    br.close()
bad = 0
for (kind, what), ts in seen.items():
    if len(ts) >= (2 if kind == '頭' else 4):
        bad += 1
        print(f"{'文字どうし' if kind == '重' else 'エイイチの頭'}: {what}  {ts[0]}〜{ts[-1]}秒（{len(ts) / 10:.1f}秒）")
print("被り", bad, "件")
```

### エイイチ「最後の「公開中」のイントネーションが変」

締めの文の声を、ハコブくんの声（ElevenLabs）で4通り作り直しました。言葉そのものを変えたものと、読み方の渡し方を変えたものです。

```text
試し_01 研究の記録は、毎日サイトで公開中です！
試し_02 研究の記録は、毎日サイトで、こうかいちゅう！
試し_03 研究の記録は、毎日サイトで公開しています！
試し_04 研究の記録は、毎日サイトで公開してます！
できあがり: voice_eleven/締め直し  使った文字数 約82字
```

エイイチが聞き比べて、A（試し_01「公開中です！」）を選びました。10本の締めの声と字幕をこの言い方にそろえ、モニターに出す文（butai_parts.py の OUTRO_TEXT）も合わせました。「全部ログに残す」だけは締めの文が「記録は、pixelstomotors.comで、毎日公開中！」とほかと違うので、同じ言い方の「…毎日公開中です！」で作り直しました（読みは narration.py の ELEVEN_READ と同じ「ピクセルズトゥモーターズ ドットコム」）。

```text
試し_01 記録は、ピクセルズトゥモーターズ ドットコムで、毎日公開中です！
できあがり: voice_eleven/締め直し  使った文字数 約32字
```

取り込んだあとの、締めの文の長さ（前後の無音を切ったあと）です。

```text
研究のスキル一覧	3.27 秒（研究の記録は、毎日サイトで公開中です！）
Claude Codeへの頼み方	3.27 秒（研究の記録は、毎日サイトで公開中です！）
寝ている間に回す	3.27 秒（研究の記録は、毎日サイトで公開中です！）
Claudeに覚えてもらう	3.27 秒（研究の記録は、毎日サイトで公開中です！）
記事の公開は誰が	3.27 秒（研究の記録は、毎日サイトで公開中です！）
別のAIに疑わせる	3.27 秒（研究の記録は、毎日サイトで公開中です！）
並行で作る	3.27 秒（研究の記録は、毎日サイトで公開中です！）
Claudeのデザイン	3.27 秒（研究の記録は、毎日サイトで公開中です！）
全部ログに残す	4.50 秒（記録は、pixelstomotors.comで、毎日公開中です！）
このショートの作り方	3.27 秒（研究の記録は、毎日サイトで公開中です！）
```

### 書き出し直した10本と、最後の点検

10本を書き出し直したあと、もう一度点検をかけました。

```text
== Claude Codeへの頼み方
被り 0 件
== 寝ている間に回す
被り 0 件
== 並行で作る
被り 0 件
== Claudeのデザイン
文字どうし: ◆ × NEW!  4.3〜5.7秒（1.5秒）
文字どうし: POINT × 重要  5.0〜20.9秒（4.0秒）
被り 2 件
== このショートの作り方
文字どうし: この部屋（Claude Co × 0  6.6〜6.9秒（0.4秒）
文字どうし: この部屋（Claude Co × 13  8.9〜10.1秒（1.3秒）
被り 2 件
== 記事の公開は誰が
被り 0 件
== Claudeに覚えてもらう
文字どうし: 動画のルールを書き足した回数 × 105  13.0〜13.8秒（0.9秒）
被り 1 件
== 別のAIに疑わせる
被り 0 件
== 研究のスキル一覧
エイイチの頭: ?  2.6〜3.5秒（1.0秒）
文字どうし: 7 × つの仕組み  4.5〜6.1秒（1.7秒）
被り 2 件
== 全部ログに残す
被り 0 件
```

残っているのは、上の表で「そのまま」にした誤検出とわざとの絵だけです。書き出したあとの動画も、0.9秒ごとのコマを並べて目で見て、被りが残っていないことを確かめました。

### X の予約の動画の差し替え

X の予約（11月17〜26日）の動画を新しい版に差し替えました。前の版は R2 に残したまま、新しい名前で入れて、投稿文の `x_video` を書き換えています。動画が pixelstomotors.com/media/ から開けること（10本とも 206 で先頭が返る）と、予約の点検（`sns_autopost.py check`）が「問題ありません」になることを確かめました。

1回目（サイトへの入口と文字の被りを直した9本・11月7日）:

```text
入れる 9・入っている 1・名前の重なり 0・合計 31.9MB
  9/9
おわり
```

2回目（締めの声を「公開中です！」にした10本・11月7日）:

```text
入れる 10・入っている 0・名前の重なり 0・合計 35.6MB
  10/10
おわり
```

いまの予約の動画です。

| 予定表の id | 動画 |
|---|---|
| BUTAI01-x | short_butai01_skills_v5.mp4 |
| BUTAI02-x | short_butai02_tanomikata_v4.mp4 |
| BUTAI03-x | short_butai03_yoru_v3.mp4 |
| BUTAI04-x | short_butai04_oboeru_v4.mp4 |
| BUTAI05-x | short_butai05_kokai_v4.mp4 |
| BUTAI06-x | short_butai06_utagau_v4.mp4 |
| BUTAI07-x | short_butai07_heiko_v3.mp4 |
| BUTAI08-x | short_butai08_design_v3.mp4 |
| BUTAI09-x | short_butai09_log_v2.mp4 |
| BUTAI10-x | short_butai10_tsukurikata_v3.mp4 |


## 次に確かめること ― X での10本の見られ方

- 11月17〜26日の X で、10本の再生と見られた長さを、同じ22:00の枠で出した AIあるある の14本と比べる。舞台型の構図が見られやすいのかを、ここで初めて数字で見る
- 10本を YouTube のショートにも上げるかは、X の結果を見てエイイチが決める。上げるときのタイトルと説明文は、KIOXIA の `videos/_ショート専用/{動画名}/ショート_タイトルと説明文.txt` に用意してある
- Unity の舞台のゆらぎの映像（stage_loop.mp4）は、まだ使っていない。背景が止まった絵で物足りなければ、ここから入れる
