---
title: "配膳ロボットの解説動画を作る ― 背景を止めず、文字はカードに"
slug: explainer-video-simulation-background
date: 2026-10-25
day: 378
meta_description: "配膳ロボットの導入シミュレーションを解説する14分20秒の動画を作った。紙の図が中心だった画面を、流れ続けるUnityの店と試算ページの映像に替え、文字は黒いカードに載せた。紙色の背景のコマは77%から0%に。本編のX投稿も自動にした。"
tags: [YouTube, 動画制作, 解説動画, 配膳ロボット, サービスロボット導入シミュレーション, Unity, ハコブくん, ElevenLabs, codex, ffmpeg, 予約投稿, GitHub Actions, Claude, AI活用]
series: "ひとり研究所の裏側"
ai_consult: true
---

解説動画の画面の8割近くが、止まった紙の図でした。前に作った配膳ロボットの解説動画（13分39秒）を5秒おきに164コマ取り出して数えると、127コマ（77.4%）が紙色の背景に図と文字を置いた画面でした。せっかく3Dの店でロボットを走らせ、ブラウザで動く試算のページも作っているのに、動画の大半ではそれが映っていませんでした。

この記事は、YouTube の解説動画「配膳ロボット、何台入れたら店は変わる？」（14分20秒）を、台本から公開の準備まで1日で作った記録です。題材は、ファミレスに配膳ロボットを入れたら待ち時間がどう変わるかを、パソコンの中で計算する試算（シミュレーション）です。お客さんが来る、料理ができる、誰かが運ぶ、皿を下げる、という出来事を時間の順に再現し、ロボットを0台から6台まで1台ずつ増やしました。動画では、案内役のロボット「ハコブくん」が AI の声で結果を話します。

途中で、画面の作りを大きく変えました。指示はこうです。

> 動画を作るときに背景にシミュレーションの動画を長く使うようにしてください（今までより背景動画を使うところは圧倒的に増やすしてください）テキストは背景をカードタイプで仕上げてください。

**背景は、章ごとに1本の映像を切らずに流し続け、文字だけを黒い角丸のカードに載せて替える形にしました。** 3Dの店の映像と試算ページの操作の録画を28本つなぎ、紙色の背景が映るコマは0になりました。カードの強調の色は最初に水色で作り、「基本はオレンジ、水色は追加の差し色」と直されて、記事や前の動画と同じオレンジに戻しています。

【画像】LOG378_02_eight_frames.jpg 仕上げた動画の8つの場面。どれも後ろで Unity の店か試算ページの映像が流れ、真ん中に黒いカード、左下にハコブくん、下に字幕がある

| やったこと | なぜ | 結果 |
|---|---|---|
| 解説動画をシリーズにして、第1回を「何台入れたら店は変わる？」にした | 試算から3Dの店（Unity）まで、1本では入りきらないため | 6回の流れを決めた。第1回は試算の回 |
| 台本を4版まで直した | 別の AI（codex）の検証で、言い過ぎを27件指摘されたため | 「1人少なくても回る」を「今回の線を下回った（待ちは延びる）」に直した |
| 背景を流れ続ける映像にし、文字はカードに載せた | 試算と3Dの店が動いているところを見せるため | 紙色の背景のコマが 77.4% から 0% に |
| カードの差し色を水色からオレンジに戻した | 記事や前の動画の色とそろえるため | 基本はオレンジ #C4471F、もう一方を見せたい語だけ水色 |
| Mac の中の作業場所で、180秒ずつに区切って書き出した | 1回の作業が180秒で止まる場所だったため | 動きの場面55個・背景の断片177個・仕上げ5区切りで14分20秒を書き出した |
| 本編の X 投稿を、YouTube の公開予約に合わせて自動で出すようにした | 動画を予約するたびに URL を貼るのは続かないため | ショートだけだった自動の予約に、本編も入るようにした |

この記事の「見込める効果」は、ことわりのない限り、まだ測っていない見込みです。数字で書いてあるもの（長さ・コマの数・処理の秒数）は、書き出した動画とログから数えた値です。動画はまだ YouTube に出ていません。

ここから下は作業の記録です。台本の直し、画面の作り、書き出しの手順、自動投稿のつなぎ方と、使ったコード・設定の全文を残します。


## 作業ログ ― 解説動画をシリーズにして、第1回を「何台入れたら」にした

最初の依頼は「サービスロボット導入シミュレーションをベースにした動画を作って欲しい」でした。サービスロボット導入シミュレーションは、配膳ロボットなどを店に入れたら何が変わるかを、入れる前に試算する記録のシリーズです（pixelstomotors.com/series8）。試算のページでは、店・人数・台数・ロボットの使い方を変えると、待ち時間の数字が変わります。その後ろでは、同じ店を Unity（ゲームを作る道具）の3Dの空間に建て、ロボットを走らせています。

はじめはシリーズ全体のまとめを1本にするつもりでしたが、方針が変わりました。

> いくつかシリーズにしたいのでまずは、配膳ロボット、何台入れたら店は変わる？のシミュレーションから始まって、その後にUnityシミュレーションにつながっていく流れを構築してほしい

そこで、記事のシリーズの中身を、動画の6回に割り振りました。

| 回 | 題 | 元にする記事 |
|---|---|---|
| 第1回 | 何台入れたら店は変わる？ | Log#1・Log#5（試算と、その点検） |
| 第2回 | 人は減らせる？ いくら浮く？ | Log#8（費用と補助金） |
| 第3回 | 店の形で答えは変わる | Log#6（店の平面図） |
| 第4回 | 3Dの店で走らせたら | Log#4・Log#7・Log#9（Unity の店） |
| 第5回 | 試算と3Dは合ったのか | Log#10・Log#11 |
| 第6回 | 導入前に、どこまで分かるか | Log#2（店で数える観察シート） |

前に作った「配膳ロボットで1人減らせるか」（未公開・試算の前の版 v2.4 の数字）は、第2回にして、いまの試算 v2.7 の数字に直すことにしました。第1回と重なる前半は抜き、声は変わった文だけ作り直します。

### 数字は、サイトの試算から読んだ

動画の数字は、すべてサイトの試算 v2.7 から読みました。試算のページには、店・人数・台数・使い方の組み合わせ16,425通りについて、それぞれ40回計算した平均が入っています。v2.7 の決まりは「料理ができたら、まずロボットを待つ。2分たってもロボットが来なければ、人も運べる」です。ロボットの速さは仮に秒速0.5メートル（人の半分）、席は空いているのに片付けや案内を待たせた時間が平均10分以上になったら「店が回らない」と数えます。

| 条件（35卓のファミレス・1時間に35組・料理を運ぶだけ） | 0台 | 1台 | 2台 | 3台 | 4台 | 5台 | 6台 |
|---|---|---|---|---|---|---|---|
| スタッフ3人：店に来てから席に着くまで（分） | 8.58 | 4.03 | 3.16 | 2.91 | 3.90 | 3.47 | 3.29 |
| スタッフ3人：料理ができてから卓に着くまで（分） | 0.97 | 2.21 | 1.02 | 0.65 | 0.55 | 0.52 | 0.51 |
| スタッフ3人：席は空いているのに待たせた時間（分） | 7.41 | 3.26 | 2.35 | 2.44 | 3.21 | 2.85 | 2.73 |
| スタッフ3人：1人が歩いた距離（km） | 3.88 | 2.69 | 2.35 | 2.34 | 2.37 | 2.38 | 2.36 |
| スタッフ3人：ロボット1台が仕事でふさがっていた割合 | — | 0.75 | 0.58 | 0.43 | 0.33 | 0.27 | 0.23 |
| スタッフ2人：店に来てから席に着くまで（分） | 28.25 | 16.85 | 14.26 | 14.17 | 14.86 | 16.07 | 15.37 |

スタッフ2人の行は、どの台数も「店が回らない」に入りました。ほかの店の種類では、焼肉の食べ放題（スタッフ2人）が0台27.45分・1台9.80分・2台6.37分、洗い場が遠い店（スタッフ2人）が0台10.94分・1台6.03分です。

### 現場を知る立場からの答えを、台本の芯にした

台本には、数字だけでなく、昔ファミレスで働いていた立場からの見立てを入れます。4つ聞いて、答えはこうでした。

> 1.意外な結果だなと。でも現時点での配膳ロボットの限界点もよくわかった。2.オレがみたのは2台くらいかな 3.お客さんの食べ終わったお皿下げてほしいし、パントリーに下げてきたお皿類を洗い場まで下げてほしい。シルバー（フォークとかスプーン）を拭いて欲しかった（地味に大変）4.ロボットを入れるだけでは良くならないこともあり、そこには専門家の知見が必要と知ってほしい

1は1台で料理が遅くなる結果への感想、2は店で見かけたロボットの台数、3はロボットに任せたかった仕事、4は見る人に持ち帰ってほしいことです。言い回しだけ整え、中身は足さずに台本に入れました。冒頭の「その店に何台いたか、覚えていますか」と「エイイチが見かけた店も、2台くらいだったそうです」は、2の答えから作っています。


## 台本は4版まで直し、codex の27件の指摘で「回る」の言い方を変えた

台本は85文、約4,984字です。書いたあと3回直しました。

| 版 | きっかけ | 直したこと |
|---|---|---|
| 1版 | シリーズにすると決めた | 第1回を「何台入れたら店は変わる？」として書いた |
| 2版 | 書いたのとは別の AI に、出典と1文ずつ照らしてもらった | 料理の遅れを2分13秒から2分12秒（2.2059分）に。「2台目まで」は席に着くまでと歩く距離に限った。2人で回らないのは「普通の混み具合で料理を運ぶだけなら」と条件を付けた |
| 3版 | codex の1回目が途中で止まったが、途中の所見が出ていた | 「1人減らして回る」は、元の人数より待ちが悪化していても通る判定だ、という所見を受けて、混雑の日・食べ放題・洗い場が遠い店の延びを足した |
| 4版 | codex の1回目（27件） | 「回る」は今回の線を下回った意味だと言い、待ちは延びると添えた。ほか下の表 |

codex（OpenAI の、コードや文書を読んで検証する道具）には、台本の言い過ぎや根拠のない事実を、反対の立場から探してもらいます。1回目は「Selected model is at capacity（モデルが混んでいる）」で止まり、かけ直して15分15秒で返ってきました。判定は「直してから」です。

> 主な問題は数字の転記よりも、独自の判定基準を通ったことを「人を減らして店が回る」と表現している点と、仮の運用ルールの結果を実機の限界に結びつけている点です。

指摘のうち大きかったものと、直し方です。

| 指摘 | 直し方 |
|---|---|
| 「1人少なくても回る」は、同じサービスのまま人を減らせたように聞こえる。料理の待ちは、混雑の日で45秒→2分17秒、食べ放題で1分17秒→3分26秒に延びている | 「今回の線を下回った」と言い換え、待ちが延びることを同じ場面で言う |
| 「いまの配膳ロボットの限界点も、よくわかった」は、製品の性能の限界が分かったように受け取られる | 本人の感想として残し、直後に「今回分かったのは、この試算の条件での限界。今の製品の性能を測ったわけではない」と添える |
| 4台で3.9分に延びたのを「ぶれの範囲」と言い切っている。40回の平均だけでは分からない | 「偶然のばらつきかどうかは、この平均だけでは分からない」に |
| 「動いていた割合」には、お客さんが料理を取るのを待つ時間も入っている | 「仕事でふさがっていた割合」に言い換え、待つ時間も含むと言う |
| 料理をロボットに載せて行き先を決める人の時間が入っていない | 「この試算でまだ分からないこと」の章で言う |
| すかいらーくの導入台数の出典が報道だけ | すかいらーくホールディングスの公式の沿革（2022年12月に約2,100店へ3,000台の導入完了）に替えた |

クイズの場面（料理が届くまでの58秒を当てる）は、冒頭と目次で答えを言ってしまっていたので外しました。ロボットの速さの冗談も外し、仮の値だと言う文にしています。「結論・立場・目次を1文にまとめる」という指摘は、チャンネルで決めている型なので採りませんでした。

codex の結果の全文は、記事の最後の「この記事で作った・直したファイルの全文」に載せています。

### 声は85文、尺は14分20秒のまま

声は ElevenLabs（AI の声を作るサービス）で、ハコブくんの声を使って Mac で作りました。85文で、長さは14分20秒（860.03秒）です。解説動画の上限の目安は13〜14分なので、文を抜いて縮める案も出しましたが、「このまま14分20秒」に決まりました。


## 画面を「紙の図」から「流れ続ける映像＋黒いカード」に替えた

声を待つ間に、画面の形を選んでもらいました。選んだのは「大文字型＋動きあり」です。言い切りたい一言を大きな文字で出し、ところどころで文字を叩きつけたり、数字を数え上げたりする作りです。

最初の版は、これまでの解説動画と同じく、紙色（#F5F5F0）の背景に図と文字を置く場面が中心でした。そこで、冒頭の指示が来ました。参考に3枚の画面写真が付いていて、どれも、暗い角丸の板に白い文字が載り、その後ろで映像が流れ続けていました（他の人の動画の画面なので、この記事には載せません）。

### 背景の映像は、試算ページの録画9本と Unity の店の映像10本

背景に使える映像を集めました。

| 種類 | 本数 | 中身 |
|---|---|---|
| 試算ページの操作の録画 | 9 | v2.7 のページをブラウザで開き、店を選ぶ・台数を増やす・1台・スタッフ2人・皿を集めて回る使い方・混雑の日・店の種類・食べ放題・洗い場が遠い店、を順に押していく様子 |
| Unity の仮のファミレス（初期の版） | 2 | Log#4 で建てた店を斜めと上から |
| Unity で建てたファミレス（図面のある店③） | 3 | Log#9 の回で、人とロボット・ロボットの往復・戻り道 |
| 同じ店の、サイトの映像 | 5 | 4つの視点・斜め・混んだ時間・すれ違い・実行中の画面（サイトの /site/srl/ に置いている映像） |

試算ページの録画は、Playwright（ブラウザを自動で動かす道具）でページを開き、押す場所に丸いカーソルを動かしてから押す、という台本を書いて録りました（`rec_背景の録画.py`）。

### 背景は、章ごとに1本の映像を切らずに流し続ける

場面（カード）が替わるたびに背景の映像を頭から流すと、数秒ごとに映像が飛びます。そこで、どの場面からどの場面まで、どの映像の何秒目から流すかを `RUNS` という表に書き、場面が替わっても背景は続きから再生するようにしました。

```python
RUNS = [
 ("u357o", 2, ["hook", "hook_q", "hook_try"]),
 ("four", 0, ["question", "define", "define2"]),
 ("u364a", 0, ["scope", "answer", "answer_two", "stance", "toc"]),
 ("busy", 0, ["c1", "setup"]),
 ("term", 0, ["simpy"]),
 ("pass", 0, ["speed"]),
 ("s_open", 0, ["entry"]),
 ("s_more", 0, ["entry_flat"]),
 ("u364c", 0, ["why1", "why1b", "why1c", "util", "walk", "c1_end"]),
 ("pass", 4, ["c2"]),
 ("s_one", 0, ["dish_ans", "dish_more"]),
 ("u364b", 0, ["rule", "rule2", "fair", "honne1", "c2_end"]),
 ("four", 8, ["c3"]),
 ("s_two", 0, ["two", "two_b", "two_c"]),
 ("u357t", 0, ["honne2", "honne2_list", "honne2_c"]),
 ("s_patrol", 0, ["patrol"]),
 ("s_crowd", 0, ["crowd", "crowd_note"]),
 ("s_table", 0, ["other"]),
 ("s_buffet", 0, ["buffet"]),
 ("s_hotel", 0, ["hotel"]),
 ("u364a", 30, ["large", "c3_end"]),
 ("obl", 0, ["mitate", "mitate1"]),
 ("u364c", 50, ["mitate2", "limits", "next"]),
 ("u364a", 50, ["answer2", "answer2b", "question_next"]),
 ("s_open", 0, ["try"]),
]
```

書き出した動画の時刻に直すと、背景は次の28本のつながりになりました（自己紹介とアウトロの3本を含む）。

| 始まり（秒） | 終わり（秒） | 背景 |
|---|---|---|
| 0.00 | 53.83 | u357o（Unity の仮のファミレス・斜め） |
| 53.83 | 65.17 | 自己紹介 |
| 65.17 | 77.57 | 自己紹介（作り手の紹介） |
| 77.57 | 117.03 | four（4つの視点） |
| 117.03 | 233.40 | u364a（人とロボット） |
| 233.40 | 262.53 | busy（上から・混んだ時間） |
| 262.53 | 280.43 | term（実行中の画面） |
| 280.43 | 291.67 | pass（上から・すれ違い） |
| 291.67 | 301.87 | s_open（試算ページ：店を選ぶ） |
| 301.87 | 323.20 | s_more（試算ページ：台数を増やす） |
| 323.20 | 396.70 | u364c（戻り道） |
| 396.70 | 402.70 | pass（上から・すれ違い） |
| 402.70 | 420.10 | s_one（試算ページ：1台） |
| 420.10 | 505.93 | u364b（ロボットの往復） |
| 505.93 | 523.57 | four（4つの視点） |
| 523.57 | 550.60 | s_two（試算ページ：スタッフ2人） |
| 550.60 | 571.67 | u357t（Unity の仮のファミレス・上から） |
| 571.67 | 590.23 | s_patrol（試算ページ：皿を集めて回る使い方） |
| 590.23 | 617.90 | s_crowd（試算ページ：混雑の日） |
| 617.90 | 634.60 | s_table（試算ページ：店の種類） |
| 634.60 | 660.10 | s_buffet（試算ページ：食べ放題） |
| 660.10 | 670.67 | s_hotel（試算ページ：洗い場が遠い店） |
| 670.67 | 689.93 | u364a（人とロボット） |
| 689.93 | 704.03 | obl（斜め） |
| 704.03 | 777.20 | u364c（戻り道） |
| 777.20 | 835.07 | u364a（人とロボット） |
| 835.07 | 844.50 | s_open（試算ページ：店を選ぶ） |
| 844.50 | 860.03 | アウトロ |

860秒のうち、Unity の店の映像が636.4秒、試算ページの録画が184.4秒、自己紹介とアウトロが39.3秒です。いちばん長く切れずに流れたのは、話の範囲から目次までの u364a（116.4秒）でした。

【図解】LOG378_background_runs.html

背景は暗くして文字を読みやすくしています。Unity の映像は明るさ0.62、試算ページの録画は0.5です。試算ページの録画は、操作が終わったら最後のコマで止めたままにし、頭に戻さないようにしました（同じ操作がくり返されると、見ている人が迷うため）。映像の左上には、何の映像かを小さな板で出します。Unity の店は、シリーズの後の回で扱う映像を先に使っているので、「Unity で建てたファミレス（図面のある店③・シリーズの後の回で扱う）」のように書きました。

### カードは、黒い角丸の板に白い文字

文字は、黒い角丸の板（不透明度86%・角の丸み30px）に白で載せます。小見出しは42pxの薄い灰色、大きい文字は太さ900、補足は44pxの薄い灰色です。カードの型は、言い切りの一言（C_word）・大きい数字（C_num）・2つの対比（C_pair）・番号つきの行（C_list）・棒グラフ（C_bars）などを作り、場面ごとの中身を `CARDS` という表に書きました。55の場面を、背景の映像の上にカードを重ねた動画として1つずつ書き出しています。

カードは、字幕と左下のハコブくんの区画にかからない高さに置きます。書き出す前に、カードの下端が785pxを超えないかを確かめる小さな検査（precheck.py）を作り、3つの場面で補足の文を縮めました。

| 場面 | 直す前に超えた所 | 縮めた補足 |
|---|---|---|
| stance | カードの下端が802px | 人に足せば、席に着くまでは縮む |
| entry_flat | 2行になり下へはみ出した | 3台目から先は3分台（4台の延びは、ばらつきか分からない） |
| limits | 2行になり下へはみ出した | 止まり方は仮・ロボットどうしの邪魔は入れていない |

### 紙色の背景が映っていたコマを数えた：77.4% → 0%

変わったことを数字で確かめるため、前の動画と今回の動画から5秒おきに1コマずつ取り出し、192×108に縮めて、字幕より上の帯（縦10〜70px）を見ました。その帯の画素のうち、紙色に近い色（赤・緑が226以上、青が216以上）の画素が45%を超えたコマを「紙色の背景」と数えています。

```bash
K=$HOME/mnt/KIOXIA/r2r2r-data/videos; cd /tmp && for v in "$K/配膳ロボットで1人減らせるか/配膳ロボットで1人減らせるか_確認用.mp4" "$HOME/build/out_確認用.mp4"; do rm -rf fr && mkdir fr && timeout 160 ffmpeg -loglevel error -i "$v" -vf "fps=1/5,scale=192:108" fr/%04d.png && python3 - <<'EOF'
import glob
from PIL import Image
fs=sorted(glob.glob('fr/*.png')); paper=0
for f in fs:
    im=Image.open(f).convert('RGB').crop((0,10,192,70)); px=list(im.getdata())
    n=sum(1 for r,g,b in px if r>225 and g>225 and b>215 and abs(r-g)<12)
    if n/len(px)>0.45: paper+=1
print(len(fs),'frames', paper,'paper', round(100*paper/len(fs),1),'%')
EOF
done
```

```
<stdin>:5: DeprecationWarning: Image.Image.getdata is deprecated and will be removed in Pillow 14 (2027-10-15). Use get_flattened_data instead.
164 frames 127 paper 77.4 %
<stdin>:5: DeprecationWarning: Image.Image.getdata is deprecated and will be removed in Pillow 14 (2027-10-15). Use get_flattened_data instead.
172 frames 0 paper 0.0 %
```

- ねらい：試算の数字が、動いている店とページから出てきたものだと、見ている間ずっと分かるようにする
- 変わったこと：紙色の背景が映っていたコマが、164コマ中127コマ（77.4%）から172コマ中0コマ（0%）になった。背景の切り替わりは28本のつながりの間の27か所だけ
- 見込める効果：途中で離れる人が減るかもしれない（見込み）。公開後に、YouTube の分析画面の「視聴者維持率」を、前に公開した解説動画と比べて確かめる


## カードの差し色を、水色からオレンジに戻した

最初の確認用の動画は、カードの強調を水色（#62BFF5）、もう一方の色を明るいオレンジ（#FF9466）にしていました。参考の画面写真が水色の強調だったためです。見たあとの返事がこれです。

> 差し色水色使ってもいいけど、基本はオレンジ色にして、追加の差し色で水色使ってください。元のオレンジと水色にしてください。

記事のサムネイルやこれまでの動画では、オレンジ #C4471F を「1画面で一番伝えたい語」に使っています。「元のオレンジ」はこの色と読み、強調をオレンジ #C4471F、もう一方を見せたい語だけ水色 #62BFF5 にしました。scenes.py で変えたのは1行です。

```python
HL, WARN = "#C4471F", "#62BFF5"   # 基本はオレンジ（元の #C4471F）、追加の差し色に水色（2026-10-24 エイイチの指示）
```

#C4471F は暗めのオレンジなので、黒い板の上で読めるかを先に3つの場面で書き出して確かめ、そのまま55場面を書き出し直しました。

【画像】LOG378_01_color_before_after.jpg 同じ3つの場面の直す前と直したあと。左は強調の語が水色、右は同じ語がオレンジで、番号もオレンジになっている

数字の棒グラフは、0台を灰色、1台と2台をオレンジにしました。「料理がロボットの帰りを待つ」のように、話の向きが逆になる語（よくなる話の中の、悪くなる所）には水色を使っています。2回目の確認用を見たあとの返事は「OKです！」でした。


## Mac の中の作業場所で、180秒ずつに区切って書き出した

今回の書き出しは、Mac 連携の作業場所（Mac の中で動く Linux の環境。4コア・メモリ3GB）で行いました。素材の映像と中間ファイルを KIOXIA（外付けの SSD）から動かさずに使えるためです。ただし、1回の命令は180秒で止まり、命令が終わると裏で動かしていた処理も止まります。14分の動画は1回では書き出せないので、どの作業も「途中から続けられる形」にしました。

### 書き出しで詰まった所と、その直し方

| 詰まった所 | 起きたこと | 直し方 |
|---|---|---|
| ブラウザ（Chromium）が起動しない | libXdamage というライブラリがない | apt-get download で取り出し、dpkg -x で展開して LD_LIBRARY_PATH に足した |
| 裏で動かした処理が消える | 命令が終わると、ローカルのサーバーや書き出しも止まる | 1回の命令の中で始めて終える長さに分けた |
| 書いたファイルがすぐ読めない | KIOXIA に書いた直後、Chromium が ERR_FILE_NOT_FOUND を出す | 中間ファイルは作業場所の手元（$HOME/build）に置き、仕上がった物だけ KIOXIA に写す。写したら15秒おいて指紋（md5）を比べる |
| 場面の表を読み込むだけで37秒 | 文ごとの時刻の割り付けを毎回計算していた | 割り付けの結果を `_timing_cache.pkl` に置いて使い回す |
| 最後のハコブくんを重ねる工程が180秒に収まらない | 25,801コマをまとめて重ねていた | fin.py で172秒ずつ5つに分け、最後につなぐ |

### 1回の命令で書き出せた量

色を直したあとに55場面を書き出し直したときの記録です。動きの場面は、1秒に約20コマ書き出せました。

| 回 | 書き出した場面 | コマ数 | かかった秒数 |
|---|---|---|---|
| 1 | hook, hook_q | 988 | 48 |
| 2 | hook_try, question, define, define2 | 上限2,800の内 | 94 |
| 3 | scope, answer, answer_two, stance, toc（c1 の途中で止まった） | 上限4,200で180秒を超えた | 172 |
| 4 | c1, setup, simpy, speed, entry, entry_flat, why1 | 2,934 | 155 |
| 5 | why1b, why1c, util, walk, c1_end, c2, dish_ans, dish_more | 2,667 | 133 |
| 6 | rule, rule2, fair, honne1, c2_end, c3, two | 3,276 | 159 |
| 7 | two_b, two_c, honne2, honne2_list, honne2_c, patrol, crowd, crowd_note, other | 3,159 | 172（最後の1場面は次の回に） |
| 8 | other, buffet, hotel, large, c3_end, mitate, mitate1 | 2,584 | 144 |
| 9 | mitate2, limits, next, answer2 | 2,760 | 163 |
| 10 | answer2b, question_next, try | 1,454 | 92 |

場面ごとに1つずつファイルを書くので、途中で止まっても、できた場面は残ります。書きかけのファイル（`.part.mp4`）は次の回の頭で消しました。

背景の断片（1文ごとに、背景の映像に字幕を重ねた短い動画）は177個です。assemble.py を何度も動かし、できている断片は使い回しました。

| 回 | できていた断片の数 |
|---|---|
| 1 | 35 |
| 2 | 67 |
| 3 | 101 |
| 4 | 132 |
| 5 | 158 |
| 6 | 177（つないで bg.mp4 を作った） |

最後にハコブくんを重ね、音と合わせました。

| 工程 | かかった秒数 |
|---|---|
| fin.py seg 0（0〜172秒） | 77 |
| seg 1 と seg 2 を同時に | 156 |
| seg 3 と seg 4 を続けて | 150 |
| join（つないで音を合わせる） | 97 |

同時に動かしても速くならなかった（4コアを1本でほぼ使い切っていた）ので、3回目は続けて動かしています。仕上がりは、YouTube に上げる版 out.mp4 が73.6MB、確認用（画質を落とした版）が37.3MB です。確認用は「30MB以下」という決まりを超えましたが、チャットで送れたのでそのまま使いました。

書き出した動画は、8か所のコマを取り出して並べ、カードと字幕とハコブくんが重なっていないかを見てから送りました。

### 書き出しの手順をまとめた3つのスクリプト

`mk.py`（動きの場面をコマ数の上限まで書き出す）・`fin.py`（ハコブくんを区切って重ねる）・`precheck.py`（カードの位置の検査）は、どの動画でも使えるので、`_制作ベース/vm_書き出し/` に置きました。全文は記事の最後に載せています。


## 概要欄・タイトル・サムネイルを作った

### 概要欄の目次は、書き出した時刻から自動で入れた

概要欄は、下書き（`概要欄_下書き.md`）に `{t:文番号}` と書いておくと、description.py が書き出した時刻に置き換えます。目次は13個、全体で2,660字になりました。

```
概要欄.txt を書きました（2660 字・目次 13 個）
▼目次
0:00 配膳ロボット、店に何台いた？
0:53 この番組について
1:17 今日の問い：何台入れたら、店は変わるのか
2:40 先に結論
3:53 1. スタッフ3人のまま、ロボットを増やすと
6:36 2. 1台だけだと、なぜ料理が遅くなるのか
8:25 3. 人を減らせる台数は、あるのか
9:10 元ファミレスのエイイチが、ロボットに任せたかった仕事
10:17 店が変わると、答えも変わる
11:29 エイイチの見立て
12:06 この試算でまだ分からないこと
12:57 冒頭の問いへの答え
14:04 エンディング
```

### 試算のページの版の表記が、v2.4 のままだった

概要欄に載せるリンクを1つずつ開いて確かめたとき、シリーズのページ（pixelstomotors.com/series8）の試算の説明に「試作 v2.4・16,425条件」と出ていました。埋め込みの試算の結果は v2.7（`"model":"v2.7"`）になっていて、表記だけが前の版のまま残っていました。動画では「試作 v2.7」と言っているので、見た人がページで食い違いに気づくことになります。

```bash
cd /home/claude/p2m-site && grep -n "試作 v2\|v2\.4" src/lib/records.ts | head; grep -n -o ".\{60\}v2\.7.\{60\}" public/sim/serving-robot.html | head -3
```

```
55:      note: '店を選び、人数・台数・ロボットの使い方を切り替えると、ピーク時の入店待ちや料理が届くまでの時間が変わります。標準ファミレスは、店の形（平面図）から選ぶこともできます。ロボットの費用と浮く人件費も比べられます（補助金の情報つき）。1つの条件を40回回した平均で、数字はすべて仮の前提による試算です（試作 v2.4・16,425条件）。',
406:s":10,"max_extra_min":600,"robot_first_wait_s":120,"model":"v2.7"},"plans":{"fam_p0":{"w":24.0,"d":19.2,"outline":[[0,0],[24
```

表記を v2.7 に直し、サイトをビルドして（652ページ・19.91秒）公開しました。

```diff
-      note: '…1つの条件を40回回した平均で、数字はすべて仮の前提による試算です（試作 v2.4・16,425条件）。',
+      note: '…1つの条件を40回回した平均で、数字はすべて仮の前提による試算です（試作 v2.7・16,425条件）。',
```

```
23:42:38 [@astrojs/sitemap] `sitemap-index.xml` created at `dist`
23:42:38 [build] 652 page(s) built in 19.91s
23:42:38 [build] Complete!
dist/series8.html
```

### タイトルは3案から、サムネイルは2案から選んでもらった

タイトルの案は3つ出しました。

| 案 | タイトル |
|---|---|
| 1 | 配膳ロボット、何台入れたら店は変わる？ 0台から6台まで試算した |
| 2 | 配膳ロボットは増やすほど良い？ ファミレスの2時間を試算したら、1台だと料理が遅くなった |
| 3 | 配膳ロボット何台で店は変わる？ 2台まで変わり、人は減らせなかった【導入シミュレーション 第1回】 |

選ばれたのは案3です。「人は減らせなかった」は、35卓のファミレス・普通の混み具合・料理を運ぶだけ、という条件の結果です。混雑の日などで1人少なくても線を下回った例は、概要欄の「先に答え」と「扱う範囲」に書いています。

サムネイルは、Unity の店の映像の1コマを背景に2案作り、左の案A（図面のある店を斜めから・「1台だと遅い？」の札）に決まりました。どちらも生成 AI の画像は使っていません。

【画像】LOG378_03_thumbnail_options.jpg サムネイルの2案。左の案Aは図面のある店を斜めから見た絵に「配膳ロボット 何台入れたら店は変わる？ 0〜6台で試算」、右の案Bは初期の店に寄った絵に「0台から6台まで試算 配膳ロボット 何台で店は変わる？」

本編から切り出すショートは作っていません。登録者や再生数が増えるまでは、指示があった動画だけ作る決まりにしているためです（2026-10-19 に決めたもの）。


## 本編の X 投稿を、YouTube の公開予約に合わせて自動で出すようにした

アップロード用の一式をそろえたところで、こう聞かれました。

> SNS投稿は自動化を進めていますが、連携できていますか？

調べると、まだつながっていませんでした。X と LinkedIn の予約投稿は、GitHub に置いた予定表（`sns/schedule.json`）を GitHub Actions が15分ごとに読み、時刻が来たものを出す仕組みです（LOG376）。YouTube のショートは、1日2回（7:47・17:47）動く予定のタスクが、Mac が1時間ごとに書き出す自分のチャンネルの一覧（`youtube_videos.json`）から公開の時刻と URL を読み、予定表に入れています（LOG377）。ところが、その同期の部品はショートだけを拾い、予定のタスクもショートのファイルしか GitHub に上げない作りでした。LOG376 の最後にも「本編の動画の投稿を予定表に入れる仕組みは、まだありません」と書いてあります。この動画の投稿文は、`投稿文_X_LinkedIn.txt` に【URL】を空けたまま置いてあるだけでした。

つなぐ作業は3つと伝え、「この形でつなげてよい」と返事をもらいました。

| 作業 | 中身 |
|---|---|
| 投稿文を置く | この動画の X の文を、`{url}` 入りの形で `sns/VIDEO_serving-robots-how-many_sns_posts.json` に置く |
| 同期の部品を広げる | `tools/sync_youtube_shorts.py` が `sns/VIDEO_*_sns_posts.json` も読み、本編の動画と結びつけて公開の時刻で予定表に入れる。URL は `https://youtu.be/{id}` |
| 予定のタスクを直す | 名前を「ショートと本編のX予約をYouTubeに合わせる」にし、本編のファイルも GitHub に上げる |

### タイトルを直して上げても結びつくように、語と長さでも照らす

本編は、YouTube に上げるときにタイトルを少し直すことがあります。チャンネルの一覧を見ると、本編のタイトルは「秒速2000ミリのロボットが、人の隣ではゆっくりな理由｜協働ロボットの安全の仕組み」のように長く、作業フォルダの名前とは違います。そこで結びつけ方を2段にしました。

1. YouTube のタイトルが、json の `youtube_title`（タイトル.txt の文）と同じもの
2. なければ、`title_match` の語（「配膳ロボット」「何台」）をすべて含み、長さが `seconds`（860秒）と3秒以内のもの

予定表に入れる時刻の決まりはショートと同じです。19:30 に別の X の予約があれば 20:00、それ以外は30分ずつ後ろへずらします。公開の時刻を過ぎてから見つけたものは入れません。LinkedIn は動画を直接アップロードする決まりなので、本編も手で出します。

### 試し：いまの一覧では何も変わらず、偽の行では 20:30 に入った

まず、いまのチャンネルの一覧で、直す前と直したあとの部品を動かしました。どちらも「変わったものはありません」で、ショートの予約は前と同じです。次に、タイトルを直して上げ、10月30日19:30（日本時間）に公開予約した、という偽の行を一覧に足して動かしました。

```bash
Y="/mnt/user-data/uploads/PixelstoMotors/デジタルツイン学習R2R2R/動画/_制作ベース/youtube_videos.json"; cd /home/claude/r2r2r-content && git show HEAD:tools/sync_youtube_shorts.py > /tmp/old_sync.py && cp /tmp/old_sync.py tools/_old_sync.py && echo "旧:" && python3 tools/_old_sync.py --videos "$Y" --dry-run; rm tools/_old_sync.py; echo "新:"; python3 tools/sync_youtube_shorts.py --videos "$Y" --dry-run
python3 - "$Y" <<'EOF'
import json,sys
d=json.load(open(sys.argv[1]))
d['動画'].append({"id":"TESTabc123","title":"配膳ロボット、何台入れたら店は変わる？ 試算した【導入シミュレーション 第1回】","privacy":"private","publishAt":"2026-10-30T10:30:00Z","published":None,"seconds":861,"is_short":False,"parent":"","url":"https://www.youtube.com/watch?v=TESTabc123"})
json.dump(d,open('/tmp/yv_fake.json','w'),ensure_ascii=False)
EOF
rm -rf /tmp/rc && cp -r /home/claude/r2r2r-content /tmp/rc && cd /tmp/rc && echo "試し（タイトルを直して上げた想定）:" && python3 tools/sync_youtube_shorts.py --videos /tmp/yv_fake.json && grep -n "VIDEO_" -A3 sns/schedule.json && python3 -c "import json;d=json.load(open('sns/VIDEO_serving-robots-how-many_sns_posts.json'));print(d['meta']['url']);print(d['posts']['x'])" && python3 tools/sns_autopost.py check; grep "2026-10-30T19:30" sns/schedule.json | head
```

```
旧:
変わったものはありません
新:
変わったものはありません
試し（タイトルを直して上げた想定）:
足した VIDEO_serving-robots-how-many-x 2026-10-30T20:30:00+09:00 配膳ロボット、何台入れたら店は変わる？ 試算した【導入シミュ
124:      "id": "VIDEO_serving-robots-how-many-x",
125:      "source": "sns/VIDEO_serving-robots-how-many_sns_posts.json",
126-      "platform": "x",
127-      "post_at": "2026-10-30T20:30:00+09:00"
128-    },
https://youtu.be/TESTabc123
配膳ロボットを0〜6台、ファミレスの混む2時間で試算。
席に着くまでが縮んだのは2台目まで。
1台だけだと、料理はかえって遅く届いた。
台数は、誰が先に運ぶかの決まりとセットで決める。
https://youtu.be/TESTabc123
#R2R2R #PixelsToMotors
問題ありません。
      "post_at": "2026-10-30T19:30:00+09:00"
      "post_at": "2026-10-30T19:30:00+09:00"
```

タイトルは json の `youtube_title` と違いますが、語と長さ（861秒）で結びつきました。その日の 19:30 には記事の投稿が、20:00 にはショートの投稿がすでに入っていたので、20:30 になっています。試しは、リポジトリを `/tmp/rc` に写した上で動かし、本物の予定表は変えていません。

試しのあとで、X の文を SNS の文章の質の決まり（1行目で手が止まる一文・シミュレーションならそう分かる言葉・小さな感想・話題のタグ2つ）に合わせて書き直しました。自分の名前のタグ（#R2R2R #PixelsToMotors）だけになっていたためです。

```
配膳ロボット、増やすほど店は良くなる？

ファミレスの混む2時間をパソコンの中で試算したら、席に着くまでが縮んだのは2台目まで。1台だと料理はかえって遅くなった…
台数は、誰が先に運ぶかの決まりとセットで決めたいね。解説動画にしたよ。
#配膳ロボット #飲食店
{url}
```

X の数え方（日本語は1字を2、URL は23）で274です。

- ねらい：本編の動画を YouTube で公開予約したら、X の投稿は何もしなくても公開の時刻に出るようにする
- 変わったこと：動画を作った会話が `sns/VIDEO_*_sns_posts.json` を置けば、予定のタスクが公開の時刻と URL を入れて予定表に足す。URL を貼る作業がなくなった
- 見込める効果：出し忘れと、公開前に URL のない投稿が出る間違いがなくなる（見込み）。この動画の公開後、`sns/posted.json` に `VIDEO_serving-robots-how-many-x` の行が「投稿済み」で入るかで確かめる

注意が1つあります。予定のタスクが動くのは 7:47 と 17:47 なので、公開予約してから X の予約に入るまで、最大で半日ほどかかります。当日の夜に公開するなら、17:47 より前に YouTube で予約しておく必要があります。

### 予定のタスクの指示（直したあとの全文）

```
あなたは、YouTube で公開予約したショートと本編の動画の X 投稿を、公開の時刻に合わせて SNS の予定表に入れる係です。短く、次の手順だけをやって終える。ほかの作業はしない。返事は日本語。

1. Mac の YouTube の一覧を取り込む。device_stage_files で次のファイルを取り込む:
   ~/Library/Mobile Documents/com~apple~CloudDocs/デスクトップ/ADAPT DESIGN/じぶんの/PixelstoMotors/デジタルツイン学習R2R2R/動画/_制作ベース/youtube_videos.json
   （Mac が毎時5分に書き出している。Mac につながらない・取り込めないときは、何もせずに「Mac につながらなかった」と1行書いて終える）
2. GitHub の PixelstoMotors/r2r2r-content を clone する（git clone --depth 1 https://github.com/PixelstoMotors/r2r2r-content）。push できなければ add_repo で push 権限つきで足してからやり直す。
3. clone したフォルダで次を実行する:
   python3 tools/sync_youtube_shorts.py --videos <取り込んだ youtube_videos.json のパス>
   「変わったものはありません」なら、何もせずに終える。
4. 変わったものがあれば、python3 tools/sns_autopost.py check が「問題ありません」になることを確かめ、python3 tools/sns_autopost.py preview を動かしてから、sns/schedule.json・sns/schedule_preview.md・sns/SHORT_ep*_sns_posts.json・sns/VIDEO_*_sns_posts.json のうち変わったものだけを git add して commit し、main に push する（git add . は使わない。push の前に git pull --rebase）。commit のメッセージは「SNS予約：ショートと本編の X を YouTube の公開予約に合わせた」と、足した・直した行の一覧。check が通らなければ push しない。
5. 最後に、足した・直した予約（id と日時）を短く書く。

注意: tools/sync_youtube_shorts.py と docs/sns_autopost.md の「YouTube のショート」「YouTube の本編」の節が正。LinkedIn の予約は入れない。予定表のほかの行（記事・マンガ）は触らない。
```


## video_rules.md に足したこと（88・89回目）

動画の作り方の決まり（video_rules.md）に、この動画で決めたことを書き足しました。iCloud の `_制作ベース/video_rules.md` と、プロジェクトの写しの両方に入れています。

### 4-4e. 背景に映像を長く流し、文字はカードに載せる（全文）

> 「何台入れたら店は変わる」（配膳ロボット導入シミュレーション 第1回）で決めた。エイイチ「背景にシミュレーションの動画を長く使う（今までより圧倒的に増やす）。テキストは背景をカードタイプで仕上げる」。4-4d の「静＝無地の背景」を、この型では「静＝映像が流れたまま、カードの文字だけが替わる」に置きかえる。
>
> - **背景:** ほぼ全部の場面で、試算ページの操作の録画か Unity の店の映像を流す。紙色の無地の場面は作らない。同じ章の間は同じ映像を切らずに流し続ける（scenes.py の RUNS に「どの場面からどの場面まで、どの映像の何秒目から」を書き、場面が替わっても続きから再生する）。映像は暗くして文字を読みやすくする（Unity は 0.62、試算ページは 0.5 の明るさ）。試算ページの録画は、操作が終わったら最後のコマで止めてよい（頭に戻さない）
> - **カード:** 黒い角丸の板（rgba(8,8,8,.86)・角の丸み30px・左右84pxの余白）に白い文字。小見出しは 42px の薄い灰色、大きい文字は 900 の太さ、補足は 44px の薄い灰色。板は字幕とハコブくんの区画にかからない高さ（画面の上150px〜下300px の間の真ん中）に置く。カードの下端が785pxを超えないかを、書き出す前に確かめる（vm_書き出し/precheck.py）
> - **色:** 基本の差し色はオレンジ #C4471F（記事・これまでの動画と同じ。強調する語・番号・章の印・棒グラフ）。追加の差し色に水色 #62BFF5（もう一方を見せたい語だけ）。最初に水色を基本にしたら、エイイチに「基本はオレンジ、追加の差し色で水色。元のオレンジと水色にして」と直された。黒い板の上でも #C4471F は読めた。暗い板の上では、青 #1A6C98 ではなく水色を使う（4-5 の「オレンジと青以外の色を足さない」の青を、この型では水色に置きかえる）
> - **映像の説明:** 映像の左上に、何の映像かを小さな板で出す（例「Unity で建てたファミレス（図面のある店③・シリーズの後の回で扱う）」「試算ページ（pixelstomotors.com/series8・試作 v2.7）」）。右上に章の番号と名前の札。数字の出どころは札の下に小さく
> - **映像と話の時期:** シリーズの後の回で扱う映像（Unity の店）を先に背景に使うときは、そう書く。Unity のエディタの道具の列や向きの印が映っていても、エイイチは通した（2026-10-24）
> - 書き方の例: `動画/何台入れたら店は変わる/scenes.py`（BG・RUNS・CARD_CSS・C_word/C_num/C_pair/C_list/C_bars と CARDS。場面ごとに MOTION[名前] = dict(type="html", bg=…, html=…) を作る）

### 7節に足した、Mac の中の作業場所で書き出すときの手順（全文）

> - **Mac の中の作業場所（device_bash）で書き出すとき（2026-10-24「何台入れたら店は変わる」）:** クラウドの作業環境が使えず、Mac 連携の shell（Linux・4コア・メモリ3GB）で書き出した。1回の呼び出しは180秒まで、呼び出しが終わるとバックグラウンドの処理も止まる。KIOXIA は書いた直後に読めないことがある（Chromium が ERR_FILE_NOT_FOUND）ので、中間ファイルは shell の手元（$HOME/build）に置き、仕上がった物だけ KIOXIA に写す（写したら15秒おいて md5 を比べる）。Chromium は libXdamage がないので、apt-get download して dpkg -x で展開し LD_LIBRARY_PATH に足す。180秒に収める部品は `_制作ベース/vm_書き出し/`: mk.py（動きの場面を、まだない物から順に、指定のコマ数まで書き出す。1秒に約30コマなので、1回 2,800〜3,000 コマ。割り付けは _timing_cache.pkl に置いて読み直さない）、assemble.py はそのまま何度も動かす（背景の断片は1回に約30個。できた物は使い回す）、fin.py（ハコブくんを重ねる所を172秒ずつ5つに分けて1つ約80秒・最後に join で音と合わせる）、precheck.py（カードがハコブくんの区画にかからないか）

### 0節の手順22を、本編の X は自動で出す形に直した（直したあとの全文）

> 22. **C** X と LinkedIn の投稿文を作る（書き方は article_rules.md の SNS 節。**動画の投稿文は日本語だけにする。英語の投稿文は作らない**（2026-10-24 エイイチの指示。英語で投稿するのはマンガだけ）。X は URL 込み140字・LinkedIn は日本語の本文と、コメント欄への補足（日本語））。LinkedIn の本文の最後には「YouTube のほかの動画: https://www.youtube.com/@PixelstoMotors」を入れる（2026-10-14 エイイチの指示。それまでの13本の投稿文にも足した）。LinkedIn の本文には、動画の URL（youtu.be など）を入れない。動画は LinkedIn に直接アップロードするので、チャンネルへのリンクだけでよい（2026-10-14 エイイチの指示）。YouTube 版の URL は、X とコメント欄の補足の出典にだけ書く。投稿文は `投稿文_X_LinkedIn.txt` にして、iCloud の作業フォルダと KIOXIA の `videos/{動画名}/アップロード用/` の両方に置く（2026-10-14 エイイチの指示。上げる物を1か所にまとめる）。**X は自動で出す（2026-10-24 エイイチの指示）:** 投稿文を作ったら、X の文を `{url}` 入りにして GitHub `r2r2r-content` の `sns/VIDEO_{英数字の名前}_sns_posts.json` に置いて push する（書き方は r2r2r-content の docs/sns_autopost.md「YouTube の本編」）。エイイチが YouTube で公開予約すると、予定のタスク「ショートと本編のX予約をYouTubeに合わせる」（7:47・17:47）が公開の時刻で予定表に入れ、URL も入れて出す。タイトルを上げるときに直しても、語（title_match）と長さ（seconds）で結びつく。**エ** YouTube で公開予約する（X の URL を貼る作業はない）。LinkedIn は動画を直接アップロードして手で出す


## まだ確かめていないこと

- 背景を流し続けたことで、途中で離れる人が減るか。公開後に、YouTube の分析画面の視聴者維持率を、前に公開した解説動画と比べる
- 本編の X 投稿が、実際に公開の時刻で予定表に入り、投稿されるか。YouTube で公開予約したあとの最初の同期（7:47 か 17:47）と、`sns/posted.json` で確かめる
- 動画の投稿文の txt（`投稿文_X_LinkedIn.txt`）の X の文は、GitHub の json に入れた書き直し前の文のまま。自動で出るのは json の文なので、txt は LinkedIn の文と補足を見るためだけに使う
- 第2回（「配膳ロボットで1人減らせるか」を v2.7 の数字に直す回）は、まだ手を付けていない


## この記事で作った・直したファイルの全文

### 動画/何台入れたら店は変わる/scenes.py

```python
# -*- coding: utf-8 -*-
# 動画「配膳ロボット、何台入れたら店は変わる？」（シリーズ「配膳ロボット導入シミュレーション」第1回）の場面
PATTERN = 2   # 画面パターン2（大文字型・video_rules.md 4-4c）
MOVE = True   # 画面型の動き（4-4d）を強弱を付けて使う（2026-10-24 エイイチが選んだ）
# 2026-10-24: 「配膳ロボットで1人減らせるか」の scenes.py の形を写して書いた
# 背景の映像は、この動画のために新しく録った（試算 v2.7 のページを、p2m-site の public/sim/serving-robot.html からローカルで開いて録画。src/rec_背景の録画.py）
#   src/sim_*.mp4    試算ページ（open: 3人で0→3台・more: 3→6台・one: 1台の料理・table: 台数の比べ・two: 2人で0→6台・crowd: 混雑で0→1台・
#                    buffet: 焼肉食べ放題2人で0→1台・hotel: 洗い場が遠い店2人で0→1台・patrol: 2人で集めて回る3台）
#   2026-10-24 台本4版（codex 1回目）に合わせて文言を直した
#   src/unity_*.mp4  Unity の仮のファミレス（「配膳ロボットで1人減らせるか」で切り出したもの）。自己紹介・問い・アウトロの背景（シリーズの後の回の予告を兼ねる）
# 強弱（video_rules.md 4-4d）:
#   中: setup・entry_flat・util・patrol・hotel（試算ページを流す、棒を足す）
#   強（10場面・続けない）: hook・question・answer・entry・dish_ans・two_b・crowd・buffet・mitate1・answer2
# 色: オレンジ（red）＝悪くなる・人に残る所、青（blue）＝ロボット・良くなる所
import os as _os, sys as _sys
_here = _os.path.dirname(_os.path.abspath(__file__))
for _c in (_os.environ.get("P2M_BASE", ""), _os.path.join(_here, "..", "base"), _os.path.join(_here, "..", "_制作ベース")):
    if _c and _os.path.exists(_os.path.join(_c, "scene_kit.py")):
        _sys.path.insert(0, _c); break
from scene_kit import *
from scene_kit import _p2page
from motion_kit import use_motion, CAP

T1 = "<b>1</b>3人のまま増やす"
T2 = "<b>2</b>1台だと遅い？"
T3 = "<b>3</b>減らせる台数"
T4 = "<b>4</b>エイイチの見立て"

SIM = lambda n, **k: dict(dict(file=f"src/sim_{n}.mp4", dim=0.55, slow=1.0), **k)   # 試算ページの録画
UO = dict(file="src/unity_oblique.mp4", dim=0.55, slow=1.2)    # Unity の店（斜め）
UT = dict(file="src/unity_top.mp4", dim=0.5, slow=1.2)         # Unity の店（真上）
CAP_SIM = "試算ページ（pixelstomotors.com/series8・試作 v2.7）"
CAP_U = "Unity の仮のファミレス（このシリーズの後の回で扱う）"
SRC_SIM = "数字：試算 v2.7（各条件40回の平均・仮の前提）"

_IDX = []


def _idx():
    """声の長さから割り付けた文の一覧（timing.layout）。重いので、tts/index.json と narration.py が同じなら作業フォルダの _timing_cache.pkl を使う"""
    if _IDX:
        return _IDX[0]
    import hashlib, pickle
    from timing import load, layout
    key = ""
    try:
        key = hashlib.md5(open(_os.path.join(_here, "tts/index.json"), "rb").read() + open(_os.path.join(_here, "narration.py"), "rb").read()).hexdigest()
        cp = _os.path.join(_here, "_timing_cache.pkl")
        if _os.path.exists(cp):
            k, idx = pickle.load(open(cp, "rb"))
            if k == key:
                _IDX.append(idx); return idx
    except Exception:
        pass
    idx = load(); layout(idx); _IDX.append(idx)
    try:
        pickle.dump((key, idx), open(_os.path.join(_here, "_timing_cache.pkl"), "wb"))
    except Exception:
        pass
    return idx


def _when(scene, k, f):
    """場面の頭から、その場面の k 番目の文の読み上げの f 割の所までの秒（声を取り込む前は 0）"""
    try:
        from timing import FPS
        lines, fr = [], 0
        for o in _idx():
            if o['scene'] == scene:
                lines.append((fr, o['speech'])); fr += o['frames']
        a, sp = lines[min(k, len(lines) - 1)]
        return (a + sp * f) / FPS
    except Exception:
        return 0.0


def bars(vals, labels, unit="分", kicker="", sub="", tag=None, colors=None, times=None, sub_t=None, vmax=None, src=SRC_SIM, fmt="{:.1f}"):
    """台数ごとの棒（縦）。ハコブくんの区画（左下）を避けるため、図は左を空けて右寄りに置く。
    times: 棒ごとに出す秒（None なら最初から出ている＝静止画）／sub_t: 下の一言を出す秒"""
    vmax = vmax or max(vals) * 1.08
    H = 340
    cols = []
    for i, (v, l) in enumerate(zip(vals, labels)):
        c = (colors or {}).get(i, "#9A9A94")
        h = max(6, int(H * v / vmax))
        ta = f' data-a="pop" data-t="{times[i]:.2f}" data-d="0.4"' if times else ""
        cols.append(f'<div{ta} style="display:flex;flex-direction:column;align-items:center;justify-content:flex-end;width:176px;height:{H + 92}px">'
                    f'<div style="font-size:60px;font-weight:900;color:{c};margin-bottom:10px">{fmt.format(v)}<span style="font-size:32px">{unit}</span></div>'
                    f'<div style="width:120px;height:{h}px;background:{c};border-radius:10px 10px 0 0"></div></div>')
    lab = "".join(f'<div style="width:176px;text-align:center;font-size:44px;font-weight:900;color:#0D0D0D;margin-top:14px">{l}</div>' for l in labels)
    k = f'<div class="p2k muted" style="margin-bottom:16px">{kicker}</div>' if kicker else ""
    st = f' data-a="up" data-t="{sub_t:.2f}"' if sub_t is not None else ""
    sb = f'<div class="p2sub"{st} style="font-size:46px;margin-top:22px">{sub}</div>' if sub else ""
    body = (f'<div class="p2wrap" style="padding-left:260px">{k}'
            f'<div style="display:flex;align-items:flex-end;gap:14px;border-bottom:4px solid #0D0D0D">{"".join(cols)}</div>'
            f'<div style="display:flex;gap:14px">{lab}</div>{sb}</div>')
    return _p2page(body, tag, src)


ENTRY3 = [8.6, 4.0, 3.2, 2.9, 3.9, 3.5, 3.3]   # 3人・席に着くまで（分）0〜6台
ENTRY2 = [28.3, 16.9, 14.3, 14.2, 14.9, 16.1, 15.4]   # 2人・席に着くまで（分）0〜6台
NL = ["0台", "1台", "2台", "3台", "4台", "5台", "6台"]
B, R_ = "#1A6C98", "#C4471F"

S = {}
# 0 フック
S["hook"] = p2_word(['約2,100店に', '<span class="blue">約3,000台</span>'], kicker="すかいらーくグループ（2022年末までに）", kc="muted", size=130, sub="単純に割ると、1店あたり平均で約1.4台", src="出典：すかいらーくHD「歴史・沿革」（2022年12月）")
S["hook_q"] = p2_quiz(['増やすほど、', '店は良くなる？'], pill="2台より3台、3台より6台", size=150)
S["hook_try"] = p2_word(['0台から6台まで', '<span class="blue">1台ずつ</span>'], kicker="ファミレスの混む2時間を、パソコンの中で", kc="muted", size=140, sub="ある台数から先は変わらない・1台だと料理が遅い")
# 2 今日の問い
S["question"] = p2_quiz(['何台入れたら、', '店は変わる？'], pill="今日の問い", size=150)
S["define"] = p2_list([("1", "席に着くまで"), ("2", "料理が届くまで"), ("3", "人を減らせるか")], kicker="見るのは3つ", size=110)
S["define2"] = p2_num("10", "分", kicker="席は空いているのに、片付けや案内を待たせた時間（平均）", sub="これ以上で「店が回らない」。比べるためにエイイチが決めた線・満席の待ちは入れない")
S["scope"] = p2_word(['パソコンの中の', '<span class="red">試算</span>'], kicker="シリーズ1回目・今日の範囲", kc="muted", size=170, sub="空の店から2時間・数字の多くは仮の値・兼務や休憩は入れていない")
# 3 結論と立場
S["answer"] = p2_word(['2台目まで', '<span class="blue">大きく変わる</span>'], kicker="先に結論：3人のまま・料理を運ぶだけ・席に着くまでの時間", kc="muted", size=150, sub="3台目から先は、大きくは縮まない")
S["answer_two"] = p2_word(['要る人数は', '<span class="red">変わらない</span>'], kicker="普通の混み具合・料理を運ぶだけなら、試した6台まで", kc="muted", size=150, sub="3人を2人にすると、店は回らない")
S["stance"] = p2_word(['意味がない、', '<span class="blue">ではない</span>'], kicker="先に言っておくと", kc="muted", size=150, sub="人に足せば席に着くまでは縮む・店や混み具合で、1台で1人少なくても線を下回る")
# 4 目次
S["toc"] = p2_list([("1", "3人のまま増やす"), ("2", "1台だと遅い？"), ("3", "減らせる台数"), ("4", "エイイチの見立て")], kicker="今日は3つ＋見立て", size=96)
# 5 節1
S["c1"] = chapter2("1つ目", "増やすと", "3人のまま、0台から6台へ")
S["setup"] = p2_pair("35卓", "1時間35組", da="標準ファミレス", db="普通の混み具合", ca="", cb="", mid="／", kicker="試算の店", size=150, tag=T1, sub="ロボットは料理を運ぶだけ・1往復で料理を3つまで")
S["simpy"] = p2_word(['出来事を', '<span class="blue">時間の順に</span>'], kicker="SimPy（Python の道具）", kc="muted", size=160, tag=T1, sub="1つの条件を40回計算した平均")
S["speed"] = p2_pair("0.5m/秒", "1.0m/秒", da="ロボット", db="人が歩く", ca="blue", cb="", mid="／", kicker="仮に置いた速さ", size=150, tag=T1, sub="どちらも仮の値（店で測ったものではない）")
S["entry"] = bars(ENTRY3[:3] + [0] * 0, NL[:3], kicker="店に来てから席に着くまで（3人）", tag=T1, colors={0: "#9A9A94", 1: B, 2: B}, vmax=9.5)
S["entry_flat"] = bars(ENTRY3, NL, kicker="店に来てから席に着くまで（3人）", tag=T1, colors={0: "#9A9A94", 1: B, 2: B, 3: B, 4: B, 5: B, 6: B}, vmax=9.5, sub="3台目から先は、3分台（4台の延びがばらつきかは、平均だけでは分からない）")
S["why1"] = p2_word(['人の手が', '<span class="blue">片付けと案内へ</span>'], kicker="料理を運ぶ仕事が、ロボットに移った", kc="muted", size=140, tag=T1)
S["why1b"] = p2_pair("7.4分", "2.3分", da="ロボットなし", db="2台", ca="", cb="blue", mid="→", kicker="席は空いているのに待たせた時間", size=170, tag=T1, src=SRC_SIM)
S["why1c"] = p2_word(['下げる・拭く・案内は', '<span class="red">人の仕事</span>'], kicker="人の仕事を待つ時間が残る（今回の作業時間と順番では）", kc="muted", size=120, tag=T1)
S["util"] = p2_num("2割", "ちょっと", kicker="6台のとき、1台が仕事でふさがっていた時間（平均）", sub="お客さんが料理を取るのを待つ時間も含む", tag=T1, src=SRC_SIM)
S["walk"] = p2_pair("3.9km", "2.3km", da="ロボットなし", db="2台", ca="", cb="blue", mid="→", kicker="スタッフ1人が歩く距離（最後の片付けまで）", size=170, tag=T1, sub="約4割減・3台目から先は変わらない", src=SRC_SIM)
S["c1_end"] = p2_word(['大きく変わったのは', '<span class="blue">2台目まで</span>'], kicker="席に着くまで・歩く距離", kc="muted", size=150, tag=T1, sub="料理を早く届けるなら、3台目にも効き目")
# 6 節2
S["c2"] = chapter2("2つ目", "1台だと", "料理が、かえって遅くなる")
S["dish"] = p2_num("58", "秒", kicker="料理ができてから席に届くまで（3人・ロボットなし）", tag=T2, src=SRC_SIM)
S["dish_q"] = p2_quiz(['1台入れたら、', 'この58秒は？'], pill="考えてみてください", size=150, tag=T2)
S["dish_ans"] = p2_pair("58秒", "2分12秒", da="ロボットなし", db="1台", ca="", cb="red", mid="→", kicker="料理ができてから卓に着くまで", size=160, tag=T2, sub="倍以上に延びた", src=SRC_SIM)
S["dish_more"] = p2_pair("約1分", "39秒", da="2台", db="3台", ca="", cb="blue", mid="→", kicker="ロボットなしより早く届いたのは、3台目から", size=170, tag=T2, src=SRC_SIM)
S["rule"] = p2_pair("まずロボット", "2分で人も", da="料理ができたら", db="ロボットが来なければ", ca="blue", cb="", mid="→", kicker="この試算の運び方の決まり（エイイチが決めた）", size=120, tag=T2)
S["rule2"] = p2_word(['料理が', '<span class="red">ロボットの帰り</span>を待つ'], kicker="1台だと、往復の間に次の料理ができる", kc="muted", size=130, tag=T2, sub="1台は、時間の約4分の3が仕事でふさがっていた")
S["fair"] = p2_pair("まずロボット", "先に空いた方", da="今の試算", db="前の版の試算", ca="blue", cb="", mid="／", kicker="どちらがいいかは、別に比べる必要がある", size=100, tag=T2, sub="前の版は、人が先に運び、ロボットの出番が減った")
S["honne1"] = p2_word(['意外。でも', '<span class="blue">限界点</span>もよく分かった'], kicker="この結果を見たエイイチの感想", kc="red", size=120, tag=T2, sub="分かったのは、この試算の条件での限界（製品の性能は測っていない）")
S["c2_end"] = p2_pair("台数", "決まり", ca="blue", cb="blue", mid="＋", kicker="何台入れるかは、セットで決める", size=190, tag=T2)
# 7 節3
S["c3"] = chapter2("3つ目", "減らせる台数", "普通の日・料理を運ぶだけなら、6台までになかった")
S["two"] = p2_num("28.3", "分", kicker="スタッフ2人・ロボットなし：席に着くまで", sub="店が回らない", tag=T3, src=SRC_SIM)
S["two_b"] = bars(ENTRY2, NL, kicker="スタッフ2人：席に着くまで", tag=T3, colors={i: R_ for i in range(7)}, vmax=30, sub="どれも「店が回らない」")
S["two_c"] = p2_word(['下げる・拭く・案内は', '<span class="red">2人のまま</span>'], kicker="運ぶ仕事はロボットへ。でも", kc="muted", size=120, tag=T3)
S["honne2"] = p2_word(['ロボットに', '<span class="blue">任せたかった仕事</span>'], kicker="昔ファミレスで働いていたエイイチに聞いた", kc="red", size=140, tag=T3)
S["honne2_list"] = p2_list([("1", "皿を下げる"), ("2", "洗い場まで運ぶ"), ("3", "シルバーを拭く")], kicker="エイイチの答え（「地味に大変」）", size=110, tag=T3)
S["honne2_c"] = p2_word(['どれも', '<span class="red">任せていない</span>'], kicker="今日の試算のロボットには", kc="muted", size=160, tag=T3)
S["patrol"] = p2_word(['2人＋3台で', '<span class="blue">回る</span>'], kicker="食事中に席を回って、皿を集める使い方", kc="muted", size=160, tag=T3, sub="この話は、次の回で", src=SRC_SIM)
S["crowd"] = p2_pair("4人", "3人", da="ロボットなし", db="1台", ca="", cb="blue", mid="→", kicker="混雑の日（1時間に45組）：店が回る最少の人数", size=190, tag=T3, src=SRC_SIM)
S["crowd_note"] = p2_pair("45秒", "2分17秒", da="4人・ロボットなし", db="3人＋1台", ca="", cb="red", mid="→", kicker="混雑の日：料理が卓に着くまで（席に着くまでも 11.4分 → 17.4分）", size=170, tag=T3, sub="同じサービスのまま減らせた、ではない", src=SRC_SIM)
S["other"] = p2_list([("・", "小型店"), ("・", "大型店"), ("・", "洗い場が遠い店"), ("・", "焼肉食べ放題")], kicker="ほかの店の種類（来客数も食事の長さも、店ごとに別の設定）", size=96, tag=T3)
S["buffet"] = p2_pair("約27.5分", "9.8分", da="2人・ロボットなし", db="2人＋1台", ca="red", cb="blue", mid="→", kicker="焼肉の食べ放題：席に着くまで", size=160, tag=T3, sub="線は下回る・料理の待ちは 1分17秒 → 3分26秒", src=SRC_SIM)
S["hotel"] = p2_pair("線の上", "線の下", da="2人・ロボットなし", db="2人＋1台", ca="red", cb="blue", mid="→", kicker="洗い場が遠い店", size=150, tag=T3, sub="席に着くまでは、3人のときの2.9分から6.0分に", src=SRC_SIM)
S["large"] = p2_pair("6人", "5人", da="0台・1台", db="2台から", ca="", cb="blue", mid="→", kicker="大型店（60卓）：店が回る最少の人数", size=190, tag=T3, src=SRC_SIM)
S["c3_end"] = p2_word(['近い店の数字は', '<span class="red">自分の店の答え</span>ではない'], kicker="何台で何が変わるかは、店と混み具合ごとに違う", kc="muted", size=110, tag=T3)
# 8 見立て
S["mitate"] = chapter2("最後に", "見立て", "エイイチの考え")
S["mitate1"] = p2_word(['入れるだけでは', '<span class="red">良くならない</span>ことも'], kicker="エイイチの見立て", kc="red", size=130, sub="そこには、専門家の知見が要る", tag=T4)
S["mitate2"] = p2_list([("1", "何台入れるか"), ("2", "誰が先に運ぶか"), ("3", "何を任せるか")], kicker="今日の比べで、答えを変えたもの", size=100, tag=T4)
S["limits"] = p2_word(['時間と数だけの', '<span class="red">計算</span>'], kicker="フェアに言うと", kc="muted", size=150, tag=T4, sub="止まり方は仮・ロボットどうしの邪魔、載せる人の時間は入れていない・今日の店は図面なし")
S["next"] = p2_list([("次", "人は減らせる？ いくら？"), ("その後", "図面から3Dの店へ")], kicker="次の回から", size=96, tag=T4)
# 9 答え
S["answer2"] = p2_word(['2台目まで', '<span class="blue">8.6分 → 3.2分</span>'], kicker="問いへの答え：3人のまま・席に着くまで", kc="muted", size=130, sub="3台目から先は、大きくは縮まない（40回の平均・仮の前提）")
S["answer2b"] = p2_word(['台数より', '<span class="blue">店と決まり</span>'], kicker="何台で変わるか", kc="muted", size=160, sub="店や混み具合で、1台で1人少なくても線を下回る（待ちは延びる）")
S["question_next"] = p2_quiz(['何台いて、', '誰が運んでいる？'], pill="次に店に行ったら", size=150)
S["try"] = p2_word(['試算は', '<span class="blue">サイトで試せる</span>'], kicker="pixelstomotors.com/series8", kc="muted", size=150, sub="リンクは概要欄に")

# 動画シーンに重ねるテロップ（透過）
O = {
 "title": overlay('<div class="lt"><img class="a" src="' + LOGO + '"><div class="b">フィジカルAI解説</div><div class="c">案内役：ハコブくん（AI音声）</div></div>'),
 "title_eiichi": overlay('<div class="lt"><img class="a" src="' + LOGO + '"><div class="b">エイイチ</div><div class="c">デザイナー／ロボティクスの研究・開発</div></div>'),
 "outro": overlay('<div class="lt"><img class="a" src="' + LOGO + '"><div class="b">相談は概要欄の連絡先から</div><div class="c">高評価・チャンネル登録／コメントはエイイチが全部読んでいます</div></div>'),
}

# 映像を流す場面 (映像, 開始秒, 透かし)。開始秒 None は前の場面の続き
VIDEO = {
 "title": ("src/unity_oblique.mp4", 2.0, False),
 "title_eiichi": ("src/unity_oblique.mp4", None, False),
 "outro": ("src/unity_top.mp4", 4.0, False),
}

# 動きのある場面（motion_kit.py が src/motion_{場面}.mp4 に書き出す）
# 2026-10-24 エイイチの指示：「背景にシミュレーションの動画を長く使う（今までより圧倒的に増やす）」「テキストは背景をカードタイプで」
#   → 自己紹介とアウトロのほかは、すべての場面で、シミュレーションの映像（Unity の店・試算ページ・試算のコード）を背景に流し、
#     その上に黒いカード（白い文字・青の強調）を置く。同じ映像は続く場面でつなげて流す（場面ごとに頭へ戻さない）
#   上の S（紙の図）は、動きを書き出す前の確かめ用に残している
import subprocess as _sp

# 背景の映像の置き場所（重いので KIOXIA。iCloud には置かない）
_BG_CANDS = [_os.environ.get("P2M_BG", ""),
             _os.path.expanduser("~/mnt/r2r2r-data/videos/何台入れたら店は変わる/_取り込み/背景"),
             "/Volumes/KIOXIA/r2r2r-data/videos/何台入れたら店は変わる/_取り込み/背景"]
BGDIR = next((c for c in _BG_CANDS if c and _os.path.isdir(c)), _BG_CANDS[1])

# 映像の名前 → (ファイル, 何の映像かの札)
U_OLD = "Unity の仮のファミレス（初期の版・シリーズの後の回で扱う）"
U_NEW = "Unity で建てたファミレス（図面のある店③・シリーズの後の回で扱う）"
BG = {
 "u357o": (f"{BGDIR}/LOG357_v1_stage2_oblique.mp4", U_OLD),
 "u357t": (f"{BGDIR}/LOG357_v2_stage2_top.mp4", U_OLD),
 "u364a": (f"{BGDIR}/LOG364_v3_run3_people_and_robots.mp4", U_NEW),
 "u364b": (f"{BGDIR}/LOG364_v4_run4_robots_back_and_forth.mp4", U_NEW),
 "u364c": (f"{BGDIR}/LOG364_v5_run5_return_lane.mp4", U_NEW),
 "four": (f"{BGDIR}/four-views.mp4", U_NEW),
 "obl": (f"{BGDIR}/fv-oblique.mp4", U_NEW),
 "busy": (f"{BGDIR}/top-busy.mp4", U_NEW),
 "pass": (f"{BGDIR}/top-pass.mp4", U_NEW),
 "term": (f"{BGDIR}/terminal.mp4", "試算のコード（SimPy）を回しているところ"),
}
for _k in ("open", "more", "one", "table", "two", "crowd", "buffet", "hotel", "patrol"):
    BG["s_" + _k] = (_os.path.join(_here, f"src/sim_{_k}.mp4"), CAP_SIM)

_DUR = {}


def _clip_len(f):
    if f not in _DUR:
        try:
            _DUR[f] = float(_sp.check_output(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", f]).decode().strip())
        except Exception:
            _DUR[f] = 9999.0
    return _DUR[f]


_START = {}


def _scene_start(scene):
    """動画の頭から、その場面の始まりまでの秒（声を取り込む前は 0）"""
    try:
        from timing import FPS
        if not _START:
            fr = 0
            for o in _idx():
                _START.setdefault(o["scene"], fr / FPS); fr += o["frames"]
        return _START.get(scene, 0.0)
    except Exception:
        return 0.0


# 背景の割り当て：(映像の名前, 頭の秒, この映像でつなげて流す場面)
RUNS = [
 ("u357o", 2, ["hook", "hook_q", "hook_try"]),
 ("four", 0, ["question", "define", "define2"]),
 ("u364a", 0, ["scope", "answer", "answer_two", "stance", "toc"]),
 ("busy", 0, ["c1", "setup"]),
 ("term", 0, ["simpy"]),
 ("pass", 0, ["speed"]),
 ("s_open", 0, ["entry"]),
 ("s_more", 0, ["entry_flat"]),
 ("u364c", 0, ["why1", "why1b", "why1c", "util", "walk", "c1_end"]),
 ("pass", 4, ["c2"]),
 ("s_one", 0, ["dish_ans", "dish_more"]),
 ("u364b", 0, ["rule", "rule2", "fair", "honne1", "c2_end"]),
 ("four", 8, ["c3"]),
 ("s_two", 0, ["two", "two_b", "two_c"]),
 ("u357t", 0, ["honne2", "honne2_list", "honne2_c"]),
 ("s_patrol", 0, ["patrol"]),
 ("s_crowd", 0, ["crowd", "crowd_note"]),
 ("s_table", 0, ["other"]),
 ("s_buffet", 0, ["buffet"]),
 ("s_hotel", 0, ["hotel"]),
 ("u364a", 30, ["large", "c3_end"]),
 ("obl", 0, ["mitate", "mitate1"]),
 ("u364c", 50, ["mitate2", "limits", "next"]),
 ("u364a", 50, ["answer2", "answer2b", "question_next"]),
 ("s_open", 0, ["try"]),
]


def _bg(scene):
    for key, ss0, scs in RUNS:
        if scene in scs:
            f, cap = BG[key]
            el = _scene_start(scene) - _scene_start(scs[0])
            ss = (ss0 + el) % max(1.0, _clip_len(f) - 0.5)
            # 試算ページの録画は、操作が終わったら最後のコマで止まっていてよいので、頭に戻さない
            if key.startswith("s_"):
                ss = min(ss0 + el, max(0.0, _clip_len(f) - 0.5))
            return dict(file=f, ss=round(ss, 2), dim=(0.5 if key.startswith("s_") else 0.62), slow=1.0), cap
    return None, ""


# ---- カード（黒い板に白い文字・強調は青）----
HL, WARN = "#C4471F", "#62BFF5"   # 基本はオレンジ（元の #C4471F）、追加の差し色に水色（2026-10-24 エイイチの指示）
CARD_CSS = f"""
.cw{{position:absolute;left:0;right:0;top:150px;bottom:300px;display:flex;align-items:center;justify-content:center}}
.cd{{background:rgba(8,8,8,.86);border-radius:30px;padding:46px 84px 52px;text-align:center;color:#fff;max-width:1560px;box-shadow:0 10px 40px rgba(0,0,0,.35)}}
.cd .k{{font-size:42px;font-weight:700;color:#BDBDBD;margin-bottom:22px;letter-spacing:.03em}}
.cd .b{{font-weight:900;line-height:1.16;letter-spacing:-.01em}}
.cd .s{{font-size:44px;font-weight:700;color:#D0D0D0;margin-top:26px;line-height:1.35}}
.cd .hl{{color:{HL}}} .cd .wn{{color:{WARN}}}
.cd .pill{{display:inline-block;font-size:38px;font-weight:900;padding:8px 28px;border-radius:999px;background:#F5F5F0;color:#0D0D0D;margin-bottom:28px}}
.cd .pr{{display:flex;align-items:center;justify-content:center;gap:60px}}
.cd .pr .w{{font-weight:900;line-height:1.1}}
.cd .pr .d{{font-size:42px;font-weight:700;color:#BDBDBD;margin-top:16px}}
.cd .pr .m{{font-size:96px;font-weight:900;color:#8A8A8A}}
.cd .li{{text-align:left;display:inline-block}}
.cd .li div{{font-weight:900;margin:8px 0}}
.cd .li span{{color:{HL};margin-right:30px}}
.cap{{position:absolute;left:96px;top:64px;background:rgba(8,8,8,.72);color:#fff;font-size:30px;font-weight:700;padding:12px 24px;border-radius:10px}}
.tg{{position:absolute;right:56px;top:64px;background:rgba(8,8,8,.72);color:#fff;font-size:30px;font-weight:900;padding:12px 24px;border-radius:10px}}
.tg b{{color:{HL};margin-right:12px}}
.sr{{position:absolute;right:56px;top:126px;color:#eee;font-size:24px;font-weight:700;text-shadow:0 1px 4px #000}}
"""
_COL = {"": "#fff", "blue": HL, "red": WARN, "muted": "#BDBDBD"}


def _wrap(inner, cap="", tag=None, src="", pop=0.05, big_at=None):
    """カード1枚の重ね絵（透過）。pop: カードが出る秒。big_at: 大きい文字を叩きつける秒（強の場面）"""
    if big_at is not None:
        inner = inner.replace('class="b"', f'class="b" data-a="slam" data-t="{big_at:.2f}" data-d="0.45"', 1)
    c = f'<div class="cap">{cap}</div>' if cap else ""
    tg = f'<div class="tg">{tag}</div>' if tag else ""
    sr = f'<div class="sr">{src}</div>' if src else ""
    body = f'<style>{CARD_CSS}</style>{c}{tg}{sr}<div class="cw"><div class="cd" data-a="pop" data-t="{pop:.2f}" data-d="0.4">{inner}</div></div>'
    return overlay(body)


def _hl(s):
    return s.replace('<span class="blue">', '<span class="hl">').replace('<span class="red">', '<span class="wn">')


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


def C_num(num, unit="", kicker="", sub="", size=230):
    k = f'<div class="k">{kicker}</div>' if kicker else ""
    s = f'<div class="s">{sub}</div>' if sub else ""
    u = f'<span style="font-size:{int(size * 0.42)}px;color:{HL};margin-left:10px">{unit}</span>' if unit else ""
    return f'{k}<div class="b" style="font-size:{size}px">{num}{u}</div>{s}'


def C_pair(a, b, da="", db="", ca="", cb="blue", mid="→", kicker="", sub="", size=140):
    k = f'<div class="k">{kicker}</div>' if kicker else ""
    s = f'<div class="s">{sub}</div>' if sub else ""
    side = lambda w, d, c: f'<div><div class="w" style="font-size:{size}px;color:{_COL[c]}">{w}</div>' + (f'<div class="d">{d}</div>' if d else "") + "</div>"
    return f'{k}<div class="b pr">{side(a, da, ca)}<div class="m">{mid}</div>{side(b, db, cb)}</div>{s}'


def C_list(items, kicker="", size=96):
    k = f'<div class="k">{kicker}</div>' if kicker else ""
    li = "".join(f'<div style="font-size:{size}px"><span>{n}</span>{x}</div>' for n, x in items)
    return f'{k}<div class="b li">{li}</div>'


def C_quiz(lines, pill, size=140):
    return f'<div class="pill">{pill}</div><div class="b" style="font-size:{size}px">{"<br>".join(lines)}</div>'


def C_chap(no, word, sub):
    return f'<div class="k" style="color:{HL};font-size:52px">{no}</div><div class="b" style="font-size:190px">{word}</div><div class="s">{sub}</div>'


def C_bars(vals, labels, kicker="", colors=None, times=None, vmax=None, sub="", sub_t=None):
    vmax = vmax or max(vals) * 1.08; Hh = 205; cols = []
    for i, (v, l) in enumerate(zip(vals, labels)):
        c = (colors or {}).get(i, "#9A9A94"); h = max(6, int(Hh * v / vmax))
        ta = f' data-a="pop" data-t="{times[i]:.2f}" data-d="0.4"' if times else ""
        cols.append(f'<div{ta} style="display:flex;flex-direction:column;align-items:center;justify-content:flex-end;width:160px;height:{Hh + 80}px">'
                    f'<div style="font-size:54px;font-weight:900;color:{c};margin-bottom:8px">{v:.1f}<span style="font-size:28px">分</span></div>'
                    f'<div style="width:104px;height:{h}px;background:{c};border-radius:10px 10px 0 0"></div></div>')
    lab = "".join(f'<div style="width:160px;text-align:center;font-size:40px;font-weight:900;margin-top:12px">{l}</div>' for l in labels)
    k = f'<div class="k">{kicker}</div>' if kicker else ""
    st = f' data-a="up" data-t="{sub_t:.2f}"' if sub_t is not None else ""
    s = f'<div class="s"{st} style="font-size:40px">{sub}</div>' if sub else ""
    return (f'{k}<div style="display:flex;gap:12px;align-items:flex-end;border-bottom:4px solid #ddd;justify-content:center">{"".join(cols)}</div>'
            f'<div style="display:flex;gap:12px;justify-content:center">{lab}</div>{s}')


# 場面ごとのカード：(中身, 強の場面なら大きい文字を叩きつける時（(文, 割合)・秒）, 章の札, 出典)
_e = [_when("entry", 0, f) for f in (0.3, 0.55, 0.8)]
_f = [_when("entry_flat", 0, f) for f in (0.0, 0.0, 0.0, 0.1, 0.35, 0.5, 0.65)]
_t2 = [_when("two_b", 0, f) for f in (0.0, 0.1, 0.25, 0.45, 0.5, 0.55, 0.6)]
GRAY, BL, OR = "#9A9A94", HL, WARN
CARDS = {
 "hook": (C_word(['約2,100店に', '<span class="blue">約3,000台</span>'], "すかいらーくグループ（2022年12月に導入完了）", "単純に割ると、1店あたり平均で約1.4台"), (1, 0.05), None, "出典：すかいらーくHD「歴史・沿革」"),
 "hook_q": (C_quiz(['増やすほど、', '店は良くなる？'], "2台より3台、3台より6台"), None, None, ""),
 "hook_try": (C_word(['0台から6台まで', '<span class="blue">1台ずつ</span>'], "ファミレスの混む2時間を、パソコンの中で", "ある台数から先は縮まない・1台だと料理が遅い"), None, None, ""),
 "question": (C_quiz(['何台入れたら、', '店は変わる？'], "今日の問い", 150), 0.1, None, ""),
 "define": (C_list([("1", "席に着くまで"), ("2", "料理が卓に着くまで"), ("3", "人を減らせるか")], "見るのは3つ"), None, None, ""),
 "define2": (C_num("10", "分", "席は空いているのに、片付けや案内を待たせた時間（平均）", "これ以上で「店が回らない」：比べるためにエイイチが決めた線"), None, None, ""),
 "scope": (C_word(['パソコンの中の', '<span class="red">試算</span>'], "シリーズ1回目・今日の範囲", "空の店から2時間・数字の多くは仮の値・兼務や休憩は入れていない", 150), None, None, ""),
 "answer": (C_word(['2台目まで', '<span class="blue">大きく縮む</span>'], "先に結論：3人のまま・料理を運ぶだけ・席に着くまで", "3台目から先は、大きくは縮まない", 150), (0, 0.6), None, SRC_SIM),
 "answer_two": (C_word(['要る人数は', '<span class="red">変わらない</span>'], "普通の混み具合・料理を運ぶだけなら、試した6台まで", "3人を2人にすると、店は回らない", 150), None, None, SRC_SIM),
 "stance": (C_word(['意味がない、', '<span class="blue">ではない</span>'], "先に言っておくと", "人に足せば、席に着くまでは縮む", 150), None, None, ""),
 "toc": (C_list([("1", "3人のまま増やす"), ("2", "1台だと遅い？"), ("3", "減らせる台数"), ("4", "エイイチの見立て")], "今日は3つ＋見立て", 90), None, None, ""),
 "c1": (C_chap("1つ目", "増やすと", "3人のまま、0台から6台へ"), None, None, ""),
 "setup": (C_pair("35卓", "1時間35組", "標準ファミレス", "普通の混み具合", "", "", "／", "試算の店", "ロボットは料理を運ぶだけ・1往復で料理を3つまで", 130), None, T1, ""),
 "simpy": (C_word(['出来事を', '<span class="blue">時間の順に</span>'], "SimPy（Python の道具）", "1つの条件を40回計算した平均", 140), None, T1, ""),
 "speed": (C_pair("0.5m/秒", "1.0m/秒", "ロボット", "人が歩く", "blue", "", "／", "仮に置いた速さ", "どちらも仮の値（店で測ったものではない）", 130), None, T1, ""),
 "entry": (C_bars(ENTRY3[:3], NL[:3], "店に来てから席に着くまで（3人）", {0: GRAY, 1: BL, 2: BL}, _e, 9.5), None, T1, SRC_SIM),
 "entry_flat": (C_bars(ENTRY3, NL, "店に来てから席に着くまで（3人）", {0: GRAY, **{i: BL for i in range(1, 7)}}, _f, 9.5,
                       "3台目から先は3分台（4台の延びは、ばらつきか分からない）", _when("entry_flat", 1, 0.1)), None, T1, SRC_SIM),
 "why1": (C_word(['人の手が', '<span class="blue">片付けと案内へ</span>'], "料理を運ぶ仕事が、ロボットに移った", "", 130), None, T1, ""),
 "why1b": (C_pair("7.4分", "2.3分", "ロボットなし", "2台", "", "blue", "→", "席は空いているのに待たせた時間", "", 160), None, T1, SRC_SIM),
 "why1c": (C_word(['下げる・拭く・案内は', '<span class="red">人の仕事</span>'], "人の仕事を待つ時間が残る（今回の作業時間と順番では）", "", 115), None, T1, ""),
 "util": (C_num("2割", "ちょっと", "6台のとき、1台が仕事でふさがっていた時間（平均）", "お客さんが料理を取るのを待つ時間も含む"), None, T1, SRC_SIM),
 "walk": (C_pair("3.9km", "2.3km", "ロボットなし", "2台", "", "blue", "→", "スタッフ1人が歩く距離（最後の片付けまで）", "約4割減・3台目から先は変わらない", 160), None, T1, SRC_SIM),
 "c1_end": (C_word(['大きく変わったのは', '<span class="blue">2台目まで</span>'], "席に着くまで・歩く距離", "料理を早く届けるなら、3台目にも効き目", 140), None, T1, ""),
 "c2": (C_chap("2つ目", "1台だと", "料理が、かえって遅くなる"), None, None, ""),
 "dish_ans": (C_pair("58秒", "2分12秒", "ロボットなし", "1台", "", "red", "→", "料理ができてから卓に着くまで", "倍以上に延びた", 150), (0, 0.45), T2, SRC_SIM),
 "dish_more": (C_pair("約1分", "39秒", "2台", "3台", "", "blue", "→", "ロボットなしより早く届いたのは、3台目から", "", 160), None, T2, SRC_SIM),
 "rule": (C_pair("まずロボット", "2分で人も", "料理ができたら", "ロボットが来なければ", "blue", "", "→", "この試算の運び方の決まり（エイイチが決めた）", "", 110), None, T2, ""),
 "rule2": (C_word(['料理が', '<span class="red">ロボットの帰り</span>を待つ'], "1台だと、往復の間に次の料理ができる", "1台は、時間の約4分の3が仕事でふさがっていた", 120), None, T2, SRC_SIM),
 "fair": (C_pair("まずロボット", "先に空いた方", "今の試算", "前の版の試算", "blue", "", "／", "どちらがいいかは、別に比べる必要がある", "前の版は、人が先に運び、ロボットの出番が減った", 100), None, T2, ""),
 "honne1": (C_word(['意外。でも', '<span class="blue">限界点</span>もよく分かった'], "この結果を見たエイイチの感想", "分かったのは、この試算の条件での限界（製品の性能は測っていない）", 110, "red"), None, T2, ""),
 "c2_end": (C_pair("台数", "決まり", "", "", "blue", "blue", "＋", "何台入れるかは、セットで決める", "", 180), None, T2, ""),
 "c3": (C_chap("3つ目", "減らせる台数", "普通の日・料理を運ぶだけなら、6台までになかった"), None, None, ""),
 "two": (C_num("28.3", "分", "スタッフ2人・ロボットなし：席に着くまで", "店が回らない"), None, T3, SRC_SIM),
 "two_b": (C_bars(ENTRY2, NL, "スタッフ2人：席に着くまで", {i: OR for i in range(7)}, _t2, 30, "どれも「店が回らない」", _when("two_b", 0, 0.8)), (0, 0.0), T3, SRC_SIM),
 "two_c": (C_word(['下げる・拭く・案内は', '<span class="red">2人のまま</span>'], "運ぶ仕事はロボットへ。でも", "", 115), None, T3, ""),
 "honne2": (C_word(['ロボットに', '<span class="blue">任せたかった仕事</span>'], "昔ファミレスで働いていたエイイチに聞いた", "", 130, "red"), None, T3, ""),
 "honne2_list": (C_list([("1", "皿を下げる"), ("2", "洗い場まで運ぶ"), ("3", "シルバーを拭く")], "エイイチの答え（「地味に大変」）", 100), None, T3, ""),
 "honne2_c": (C_word(['どれも', '<span class="red">任せていない</span>'], "今日の試算のロボットには", "", 150), None, T3, ""),
 "patrol": (C_word(['2人＋3台で', '<span class="blue">線を下回る</span>'], "食事中に席を回って、お客さんに皿を載せてもらう使い方", "お客さんが一定の量を載せてくれる前提・この話は次の回で", 140), None, T3, SRC_SIM),
 "crowd": (C_pair("4人", "3人", "ロボットなし", "1台", "", "blue", "→", "混雑の日（1時間に45組）：線を下回る最少の人数", "", 180), (0, 0.55), T3, SRC_SIM),
 "crowd_note": (C_pair("45秒", "2分17秒", "4人・ロボットなし", "3人＋1台", "", "red", "→", "料理が卓に着くまで（席に着くまでも 11.4分 → 17.4分）", "同じサービスのまま減らせた、ではない", 150), None, T3, SRC_SIM),
 "other": (C_list([("・", "小型店"), ("・", "大型店"), ("・", "洗い場が遠い店"), ("・", "焼肉食べ放題")], "ほかの店の種類（来客数も食事の長さも、店ごとに別の設定）", 90), None, T3, ""),
 "buffet": (C_pair("約27分半", "9.8分", "2人・ロボットなし", "2人＋1台", "red", "blue", "→", "焼肉の食べ放題：席に着くまで", "線は下回る・料理の待ちは 1分17秒 → 3分26秒", 140), (0, 0.6), T3, SRC_SIM),
 "hotel": (C_pair("2.9分", "6.0分", "3人・ロボットなし", "2人＋1台", "", "red", "→", "洗い場が遠い店：席に着くまで", "2人＋1台でも、線は下回る", 150), None, T3, SRC_SIM),
 "large": (C_pair("6人", "5人", "0台・1台", "2台から", "", "blue", "→", "大型店（60卓）：線を下回る最少の人数", "", 180), None, T3, SRC_SIM),
 "c3_end": (C_word(['近い店の数字は', '<span class="red">自分の店の答え</span>ではない'], "何台で何が変わるかは、店と混み具合ごとに違う", "", 105), None, T3, ""),
 "mitate": (C_chap("最後に", "見立て", "エイイチの考え"), None, None, ""),
 "mitate1": (C_word(['入れるだけでは', '<span class="red">良くならない</span>ことも'], "エイイチの見立て", "そこには、専門家の知見が要る", 125, "red"), (0, 0.1), T4, ""),
 "mitate2": (C_list([("1", "何台入れるか"), ("2", "誰が先に運ぶか"), ("3", "何を任せるか")], "今日の比べで、答えを変えたもの", 96), None, T4, ""),
 "limits": (C_word(['時間と数だけの', '<span class="red">計算</span>'], "フェアに言うと", "止まり方は仮・ロボットどうしの邪魔は入れていない", 140), None, T4, ""),
 "next": (C_list([("次", "人は減らせる？ いくら？"), ("その後", "図面から3Dの店へ")], "次の回から", 92), None, T4, ""),
 "answer2": (C_word(['2台目まで', '<span class="blue">8.6分 → 3.2分</span>'], "問いへの答え：3人のまま・席に着くまで", "3台目から先は、大きくは縮まない（40回の平均・仮の前提）", 130), (1, 0.3), None, SRC_SIM),
 "answer2b": (C_word(['台数より', '<span class="blue">店と決まり</span>'], "何台で変わるか", "店や混み具合で、1台で1人少なくても線を下回る（待ちは延びる）", 150), None, None, ""),
 "question_next": (C_quiz(['何台いて、', '誰が運んでいる？'], "次に店に行ったら", 140), None, None, ""),
 "try": (C_word(['試算は', '<span class="blue">サイトで試せる</span>'], "pixelstomotors.com/series8", "リンクは概要欄に", 140), None, None, ""),
}

MOTION = {}
for _name, (_inner, _big, _tag, _src) in CARDS.items():
    _b, _cap = _bg(_name)
    _ba = None
    if _big is not None:
        _ba = _big if isinstance(_big, (int, float)) else _when(_name, _big[0], _big[1])
    MOTION[_name] = dict(type="html", bg=_b, html=_wrap(_inner, cap=_cap, tag=_tag, src=_src, big_at=_ba))
use_motion(MOTION, VIDEO, O, _here, MOVE)
```

### 動画/_制作ベース/vm_書き出し/mk.py

```python
# motion_kit.main を、割り付けのキャッシュを使って、まだ書き出していない場面から順に、時間内に収まる分だけ回す
import sys, os, copy, pickle, time
B = os.environ['P2M_BASE']; sys.path.insert(0, os.getcwd()); sys.path.insert(1, B)
import timing
key, cached = pickle.load(open('_timing_cache.pkl', 'rb'))
timing.load = lambda first=None: copy.deepcopy(cached)
timing.layout = lambda idx: sum(o['frames'] for o in idx)
import scenes as SC, motion_kit as MK
budget = int(sys.argv[1]) if len(sys.argv) > 1 else 1500
force = sys.argv[2:]  # 作り直す場面
fr = {}
for o in cached:
    fr[o['scene']] = fr.get(o['scene'], 0) + o['frames']
todo, tot = [], 0
for name in SC.MOTION:
    if name not in fr: continue
    if os.path.exists(f'src/motion_{name}.mp4') and name not in force: continue
    if todo and tot + fr[name] > budget: break
    todo.append(name); tot += fr[name]
rest = [n for n in SC.MOTION if n in fr and not os.path.exists(f'src/motion_{n}.mp4') and n not in todo]
print('この回:', todo, tot, 'frames / 残り', len(rest), '場面', sum(fr[n] for n in rest), 'frames', flush=True)
if todo:
    sys.argv = ['motion_kit.py'] + todo
    MK.main()
```

### 動画/_制作ベース/vm_書き出し/fin.py

```python
# assemble.py の最後（ハコブくんを重ねる・確認用）を、1回の時間に収まるよう区切って書き出す
#   python3 fin.py prep          ハコブくんの動きを決めて params.pkl に置く
#   python3 fin.py seg N SEC     N 番目の区切り（SEC 秒ずつ）を segs/seg_N.mp4 に（crf 20）と segs/chk_N.mp4（crf 29）に
#   python3 fin.py join          つないで音を合わせ、out.mp4 と out_確認用.mp4 を作る
import sys, os, json, pickle, subprocess, wave
B = os.environ['P2M_BASE']; sys.path.insert(0, os.getcwd()); sys.path.insert(1, B)
import numpy as np
from timing import FPS
from hakobu import Hakobu
hk = Hakobu()
idx = json.load(open('timeline.json'))
total = sum(o['frames'] for o in idx)
cmd = sys.argv[1]
if cmd == 'prep':
    w = wave.open('narration.wav'); pcm = np.frombuffer(w.readframes(w.getnframes()), np.int16)
    timeline = [(o['start'], o['start'] + o['frames'], o['start'] + o['speech'], o.get('face', 'base'), bool(o['text'])) for o in idx]
    params = hk.animate(timeline, pcm.astype(np.float32) / 32768.0)
    pickle.dump(params, open('params.pkl', 'wb')); print('frames', total, len(params))
elif cmd == 'seg':
    n, sec = int(sys.argv[2]), float(sys.argv[3]); L = int(sec * FPS)
    a, b = n * L, min(total, (n + 1) * L)
    if a >= total: sys.exit('範囲の外')
    params = pickle.load(open('params.pkl', 'rb'))
    os.makedirs('segs', exist_ok=True)
    out = f'segs/seg_{n:02d}.mp4'
    c = ['ffmpeg', '-v', 'error', '-y', '-ss', f'{a / FPS:.4f}', '-i', 'bg.mp4',
         '-f', 'rawvideo', '-pix_fmt', 'rgba', '-s', f'{hk.w}x{hk.h}', '-r', str(FPS), '-i', '-',
         '-filter_complex', f'[0:v][1:v]overlay=x={hk.x}:y={hk.y}:eof_action=pass,split[v1][v2]',
         '-map', '[v1]', '-c:v', 'libx264', '-preset', 'faster', '-crf', '20', '-pix_fmt', 'yuv420p', '-frames:v', str(b - a), '-an', out + '.part.mp4',
         '-map', '[v2]', '-c:v', 'libx264', '-preset', 'faster', '-crf', '29', '-pix_fmt', 'yuv420p', '-frames:v', str(b - a), '-an', f'segs/chk_{n:02d}.part.mp4']
    p = subprocess.Popen(c, stdin=subprocess.PIPE)
    for i in range(a, b):
        p.stdin.write(hk.frame(params[i]).tobytes())
    p.stdin.close()
    if p.wait(): sys.exit('失敗')
    os.replace(out + '.part.mp4', out); os.replace(f'segs/chk_{n:02d}.part.mp4', f'segs/chk_{n:02d}.mp4')
    print('seg', n, a, b, 'of', total)
elif cmd == 'join':
    n = int(sys.argv[2])
    for kind, outn, ab in (('seg', 'out.mp4', '192k'), ('chk', 'out_確認用.mp4', '112k')):
        open('segs/list.txt', 'w').write(''.join(f"file '{kind}_{i:02d}.mp4'\n" for i in range(n)))
        subprocess.run(['ffmpeg', '-v', 'error', '-y', '-f', 'concat', '-safe', '0', '-i', 'segs/list.txt', '-i', 'mix.wav', '-map', '0:v', '-map', '1:a',
                        '-c:v', 'copy', '-af', 'loudnorm=I=-16:TP=-1.5:LRA=11', '-c:a', 'aac', '-b:a', ab, '-ar', '48000', '-movflags', '+faststart',
                        '-frames:v', str(total), outn], check=True)
        print(outn, os.path.getsize(outn) / 1e6, 'MB')
```

### 動画/_制作ベース/vm_書き出し/precheck.py

```python
import sys, os, copy, pickle
B = os.environ['P2M_BASE']; sys.path.insert(0, os.getcwd()); sys.path.insert(1, B)
import scenes as SC, motion_kit as MK
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
    b = p.chromium.launch(); pg = b.new_page(viewport={'width': 1920, 'height': 1080})
    for nm, m in SC.MOTION.items():
        open('/tmp/pc.html', 'w').write(MK.anim(m['html'])); pg.goto('file:///tmp/pc.html'); pg.evaluate('seek(999)')
        r = pg.evaluate("(()=>{const e=document.querySelector('.cd'); if(!e) return null; const r=e.getBoundingClientRect(); return [r.left,r.top,r.right,r.bottom]})()")
        if r and (r[3] > 785 or r[1] < 118):
            print(nm, [round(x) for x in r])
    b.close()
print('done')
```

### 動画/何台入れたら店は変わる/src/rec_背景の録画.py

```python
# 第1回「何台入れたら店は変わる」の背景の録画（試算 v2.7 のページをローカルで開いて録る）
# 使い方: simulation/v2_7 で python3 -m http.server 8765 を動かし、xvfb-run -s '-screen 0 1920x1400x24' python3 rec_背景の録画.py [名前 ...]
# 「配膳ロボットで1人減らせるか」の src/rec_背景の録画.py を写して書き換えた（2026-10-24）
import asyncio, sys, os, subprocess
from playwright.async_api import async_playwright
URL = 'http://localhost:8765/serving-robot.v27.html'   # iCloud simulation/v2_7/serving-robot.v27.html（p2m-site public/sim/serving-robot.html と同じ md5 88adf2b2）
CUR = """(() => { if (document.getElementById('fakecur')) return;
 const c=document.createElement('div'); c.id='fakecur';
 c.style.cssText='position:fixed;left:960px;top:700px;width:34px;height:34px;margin:-17px 0 0 -17px;border-radius:50%;background:rgba(240,90,40,.35);border:3px solid #F05A28;z-index:99999;pointer-events:none;transition:left .7s ease, top .7s ease, transform .15s';
 document.body.appendChild(c);})()"""
async def cur_to(pg, loc):
    await loc.scroll_into_view_if_needed()
    bb = await loc.bounding_box(); x, y = bb['x']+bb['width']/2, bb['y']+bb['height']/2
    await pg.evaluate(f"(()=>{{const c=document.getElementById('fakecur');c.style.left='{x}px';c.style.top='{y}px'}})()")
    await pg.wait_for_timeout(800)
async def click(pg, loc, wait=1400):
    await cur_to(pg, loc)
    await pg.evaluate("(()=>{const c=document.getElementById('fakecur');c.style.transform='scale(.7)';setTimeout(()=>c.style.transform='',150)})()")
    await loc.click(); await pg.wait_for_timeout(wait)
async def scroll_to(pg, y, ms=1200):
    await pg.evaluate(f"window.scrollTo({{top:{y},behavior:'smooth'}})"); await pg.wait_for_timeout(ms)
async def ytext(pg, text):
    return await pg.evaluate(f"(()=>{{const w=document.createTreeWalker(document.body,NodeFilter.SHOW_TEXT);let n;while(n=w.nextNode()){{if(n.textContent.trim().startsWith({text!r})){{const r=n.parentElement.getBoundingClientRect();return r.top+window.scrollY}}}}return 0}})()")
async def mark_text(pg, text):
    r = await pg.evaluate(f"(()=>{{const w=document.createTreeWalker(document.body,NodeFilter.SHOW_TEXT);let n;while(n=w.nextNode()){{if(n.textContent.trim().startsWith({text!r})){{const r=n.parentElement.getBoundingClientRect();if(r.top>0&&r.top<1080)return [r.left+r.width/2,r.top+r.height/2]}}}}return null}})()")
    if r:
        await pg.evaluate(f"(()=>{{const c=document.getElementById('fakecur');c.style.left='{r[0]}px';c.style.top='{r[1]+40}px'}})()"); await pg.wait_for_timeout(900)
async def seg(pg, sid, v, wait=1500):
    inp = pg.locator(f'#{sid} input[value="{v}"]')
    if await inp.count():
        await click(pg, pg.locator(f'#{sid} label[for="' + (await inp.get_attribute('id')) + '"]'), wait)
    else:
        await click(pg, pg.locator(f'#{sid} button.more'), 900)
        await click(pg, pg.locator(f'#{sid}-panel button[data-v="{v}"]'), wait)
async def segi(pg, sid, i, wait=1500):   # 何番目のボタン（混み具合など値が名前でないもの）
    await click(pg, pg.locator(f'#{sid} label[for="{sid[4:] if False else ""}"]') if False else pg.locator(f'#{sid} label').nth(i), wait)
async def store(pg, i, wait=1500): await click(pg, pg.locator(f'#stores label[for="store-{i}"]'), wait)
async def mode(pg, i, wait=1500): await click(pg, pg.locator(f'#modes label[for="mode-{i}"]'), wait)
RS, ST = 'seg-robots', 'seg-staff'
RES = 600   # 条件と結果のカードが見える位置

async def clip(name, fn):
    os.makedirs('out', exist_ok=True)
    async with async_playwright() as p:
        b = await p.chromium.launch(headless=False, args=['--window-position=0,0','--window-size=1920,1300','--hide-scrollbars','--force-device-scale-factor=1'])
        ctx = await b.new_context(no_viewport=True); pg = await ctx.new_page()
        await pg.goto(URL, wait_until='networkidle')
        await pg.add_style_tag(content='html{scroll-behavior:auto} ::-webkit-scrollbar{display:none}')
        await pg.evaluate(CUR); await pg.wait_for_timeout(1200)
        top = await pg.evaluate('window.outerHeight-window.innerHeight'); ih = await pg.evaluate('window.innerHeight')
        if ih < 1080: raise SystemExit(f'inner {ih}')
        pre = getattr(fn, 'pre', None)
        if pre: await pre(pg)
        rec = subprocess.Popen(['ffmpeg','-v','error','-y','-f','x11grab','-framerate','30','-video_size','1920x1080','-draw_mouse','0','-i',os.environ.get('DISPLAY',':99')+f'.0+0,{top}',
                                '-c:v','libx264','-preset','veryfast','-crf','14','-pix_fmt','yuv420p',f'out/{name}.mp4'], stdin=subprocess.PIPE)
        await pg.wait_for_timeout(700); await fn(pg); rec.communicate(b'q')
        await ctx.close(); await b.close(); print('done', name, flush=True)

def pre_scroll(y):
    async def f(pg): await pg.evaluate(f"window.scrollTo(0,{y})"); await pg.wait_for_timeout(600)
    return f

async def c_open(pg):          # 標準ファミレス・3人：0→1→2→3台
    await pg.wait_for_timeout(1200); await scroll_to(pg, RES, 1500)
    for n in (0, 1, 2, 3): await seg(pg, RS, n, 1800)
    await pg.wait_for_timeout(1500)
async def c_more(pg):          # 3人：3→4→5→6台
    for n in (3, 4, 5, 6): await seg(pg, RS, n, 1700)
    await pg.wait_for_timeout(1500)
c_more.pre = pre_scroll(RES)
async def c_one(pg):           # 3人・1台：料理が届くまで 2分12秒
    await seg(pg, RS, 1, 1800); await mark_text(pg, '料理ができてから届くまで'); await pg.wait_for_timeout(3500)
    await seg(pg, RS, 2, 1800); await mark_text(pg, '料理ができてから届くまで'); await pg.wait_for_timeout(2500)
c_one.pre = pre_scroll(RES)
async def c_table(pg):         # 「ロボットの台数を変えると」の比べ
    y = await ytext(pg, 'ロボットの台数を変えると'); await scroll_to(pg, y-80, 2200); await pg.wait_for_timeout(5000)
async def c_two(pg):           # 2人：0→1→2→3→6台
    await seg(pg, ST, 2, 1800)
    for n in (1, 2, 3, 6): await seg(pg, RS, n, 1600)
    await pg.wait_for_timeout(1500)
c_two.pre = pre_scroll(RES)
async def c_crowd(pg):         # 混雑・3人：0→1台
    await segi(pg, 'seg-arr', 2, 1800); await seg(pg, RS, 0, 1800); await seg(pg, RS, 1, 2500)
c_crowd.pre = pre_scroll(RES)
async def c_buffet(pg):        # 焼肉食べ放題・2人：0→1台
    await store(pg, 4, 1500); await scroll_to(pg, RES, 1200); await seg(pg, ST, 2, 1500); await seg(pg, RS, 0, 1600); await seg(pg, RS, 1, 2500)
async def c_hotel(pg):         # 洗い場が遠い店・2人：0→1台
    await store(pg, 3, 1500); await scroll_to(pg, RES, 1200); await seg(pg, ST, 2, 1500); await seg(pg, RS, 0, 1600); await seg(pg, RS, 1, 2500)
async def c_patrol(pg):        # 2人・食事中に集めて回る・3台
    await seg(pg, ST, 2, 1500); await mode(pg, 2, 1800); await seg(pg, RS, 3, 3000)
c_patrol.pre = pre_scroll(RES)
CL = dict(open=c_open, more=c_more, one=c_one, table=c_table, two=c_two, crowd=c_crowd, buffet=c_buffet, hotel=c_hotel, patrol=c_patrol)
async def main():
    for k in (sys.argv[1:] or CL): await clip('sim_'+k, CL[k])
asyncio.run(main())
```

### 動画/何台入れたら店は変わる/narration.py（台本・4版）

```python
# -*- coding: utf-8 -*-
# 台本「配膳ロボット、何台入れたら店は変わる？」— シリーズ「配膳ロボット導入シミュレーション」第1回（/series8 の試算が元の解説回）
#   2026-10-24 4版。codex 1回目（「直してから」・結論「条件つきで成立」・27件）を受けた:
#     「回る」は今回の線を下回った意味・人を減らすと料理の待ちが延びる（混雑 45秒→2分17秒・食べ放題 1分17秒→3分26秒）・同じサービスのまま減らせたではない／
#     本人の「限界点」の後に、この試算の条件での限界と断る／4台のぶれは言い切らない／動いていた割合→仕事でふさがっていた割合（受け取り待ちを含む）／
#     空の店から始め片付けまで・ホールの仕事を何人でこなせるか（兼務・準備・休憩は外）・料理を載せる人の時間は入れていない・停止は台数に関係なく同じ確率／
#     料理は3卓ではなく3つまで・デザートとドリンクも／店の普通の混み具合はそれぞれ別の設定／食べ放題は約27分半（ページの表示は27.4）・約18分縮む／
#     ハコブくんのまとめ（見立てのあと）は、店長やスタッフ・メーカー・導入を手伝う人と一緒に確かめる、に（宣伝に聞こえすぎないように）／
#     すかいらーくは公式の沿革（2022年12月に約2,100店・3,000台の導入完了）。クイズ（料理の58秒）は、冒頭と目次で答えを言っていたので外した／速さの冗談は外した（仮の値だと言う）
#     採らなかった: 結論・立場・目次を1文にまとめる案（チャンネルの型）
#   2026-10-24 3版。codex 1回目は混雑で止まったが、途中の所見「1人減らして回るは、元の人数より待ちが悪化していても成立する判定」を受けて、
#     混雑の日（4人0台 11.4分 → 3人1台 17.4分）・食べ放題（3人0台 9.2分 → 2人1台 9.8分）・洗い場が遠い店（2.9分 → 6.0分）の延びを足した
#   2026-10-24 2版。別のエージェントの照合（食い違い・根拠なし・言い過ぎ）を受けて直した: 2分12秒（2.2059分）・「2台目まで」は席に着くまでと歩く距離に限る・
#     2人で回らないは「普通の混み具合で料理を運ぶだけなら」・混雑では1台で4人→3人（席はあるのに 18.0→9.3分）を足した・食事中に皿を集めて回る使い方は2人＋3台で回る（7.5分）を次回へのつなぎに・
#     歩く距離は「2時間に来たお客さんを送り出すまで」・40回のぶれは弱めた・食べ放題はぎりぎり（席はあるのに9.19分）・図面は今日の店では使っていない・
#     見立ては本人の言葉（専門家の知見）だけをエイイチの見立てにし、台数・決まり・任せる仕事の並びはハコブくんのまとめにした
#   2026-10-24 1版。エイイチがシリーズにすると決め、第1回を「何台入れたら店は変わる？」にした（試算 → 費用 → 店の形 → Unity の順につなぐ）
#   エイイチの実感（2節26。言い回しだけ整え、中身は足していない）:
#     - 1台で料理が遅くなる結果に「意外な結果だなと。でも現時点での配膳ロボットの限界点もよくわかった」
#     - 見かけた店のロボットは「2台くらいかな」
#     - 任せたかった仕事「お客さんの食べ終わったお皿下げてほしいし、パントリーに下げてきたお皿類を洗い場まで下げてほしい。
#       シルバー（フォークとかスプーン）を拭いて欲しかった（地味に大変）」
#     - 見る人に持ち帰ってほしいこと「ロボットを入れるだけでは良くならないこともあり、そこには専門家の知見が必要と知ってほしい」
#   数字の出どころ: サイトの試算 v2.7（public/sim/serving-robot.html に埋め込みの結果・16,425条件×40回）。2026-10-24 に p2m-site の
#     src/lib/servingSim.ts の find() で読んだ。条件は「普通の混み具合・料理を運ぶだけ・1往復で最大3卓・壊れやすい皿は誰が運んでもよい」
#     入店待ち＝entry_wait_mean（店に来てから案内されて卓に着くまで）／席はあるのに待った＝staff_wait_mean／料理＝dish_wait_mean（料理ができてから卓に届くまで）
#     - 標準ファミレス（35卓・35組/時）スタッフ3人 0〜6台: 入店 8.58/4.03/3.16/2.91/3.90/3.47/3.29分・
#       料理 0.97/2.21/1.02/0.65/0.55/0.52/0.51分（58秒/2分12秒（2.2059分）/約1分（61.5秒）/39秒/33秒/31秒/31秒）・席はあるのに 7.41/3.26/2.35/2.44/3.21/2.85/2.73分・
#       歩く距離 3.88/2.69/2.35/2.34/2.37/2.38/2.36km・ロボットが動いている割合 —/0.75/0.58/0.43/0.33/0.27/0.23
#     - 同 スタッフ2人 0〜6台: 入店 28.25/16.85/14.26/14.17/14.86/16.07/15.37分（すべて「店が回らない」）
#     - 焼肉食べ放題（35卓・20組/時・150分）スタッフ2人 0/1/2台: 27.45（回らない）/9.80/6.37分
#     - 洗い場が遠い店（30卓・22組/時）スタッフ2人 0/1台: 10.94（回らない）/6.03分
#     - 同 混雑（45組/時）店が回る最少のスタッフ 0/1台: 4/3人（3人0台は席はあるのに18.0分・3人1台は9.3分）
#     - 同 スタッフ2人・食事中に皿を集めて回る使い方 3台: 入店7.5分（回る）
#     - 大型店（60卓・60組/時）店が回る最少のスタッフ 0/1/2/3台: 6/6/5/5人
#     - 前提: ロボット 秒速0.5m・人 秒速1.0m・料理はまずロボットを待ち、2分（robot_first_wait_s=120）たっても来なければ人も運べる（v2.7・2026-10-23 エイイチと決めた）
#       一時停止は往復の30%で3〜10秒・立ち往生は往復の1%（pause_rate 0.3・stuck_rate 0.01）。店が回らない＝席はあるのに待った時間が平均10分以上（LOG359）
#   出典（台本の中の事実）:
#     - hook: すかいらーくグループ 2022年末までに約2,100店・約3,000台（Business Insider Japan https://www.businessinsider.jp/article/267599/ ・LOG351）
#       1.4台は 3,000÷2,100 を単純に割った値（店ごとの台数ではない）
#     - fair: v2.6 までは「先に空いた方が運ぶ」で、受け渡し口の横のスタッフが多く運んでいた（haizen_sim.py v2.7 の注記・LOG367）
#     - buffet_why: 運ぶ仕事が重い店ほど効く（LOG351 の言い方）
# 何の動画か（5行・video_rules.md 2節16）
#   1. 配膳ロボットは、何台入れたら店が変わるのか（シリーズ1回目・試算の回）
#   2. 35卓のファミレスでスタッフ3人のまま0〜6台: 席に着くまで 8.6→3.2分（2台）。3台目からはほぼ変わらない
#   3. 1台だけだと料理はかえって遅くなる（58秒→2分13秒）。料理がロボットを待つ決まりのため。台数と決まりはセットで決める
#   4. 何台入れても、スタッフ2人では回らない。変わるのは待ちで、人数ではない。ただし焼肉食べ放題・洗い場が遠い店は1台で2人でも回る
#   5. 見立て: ロボットを入れるだけでは良くならないこともある。台数・決まり・任せる仕事を、店を見て決める知見が要る。次回から費用・図面・3Dの店へ
# 話すのはハコブくん。エイイチは三人称で呼ぶ（video_rules.md 3-1）
#
# 各要素: (場面の名前, 表情, 字幕の文, 後ろの間[秒])  間を省略すると 0.35 秒
# 字幕の文の中の「｜」: 1文を読み上げたまま、字幕だけをそこで次の1枚に替える
FACES = ("base", "smile", "surprise", "doubt", "think", "trouble", "joy", "wink")

SEGS = [
 # 0 フック
 ("hook", "base", "ファミレスで、ネコの顔をした配膳ロボットを見かけたとき、その店に何台いたか、覚えていますか。"),
 ("hook", "base", "すかいらーくグループは、2022年の12月に、約2,100店へ、約3,000台の導入を終えました。｜単純に割ると、1店あたり平均で約1.4台です。"),
 ("hook", "smile", "エイイチが見かけた店も、2台くらいだったそうです。", 1.2),
 ("hook_q", "doubt", "では、2台より3台、3台より6台。ロボットを増やせば増やすほど、店は良くなるのでしょうか。", 1.2),
 ("hook_try", "think", "そこで、ファミレスの混む時間帯の2時間を、パソコンの中で再現して、ロボットを0台から6台まで、1台ずつ増やしてみました。"),
 ("hook_try", "surprise", "すると、ある台数から先は、席に着くまでの時間が、ほとんど縮まなくなりました。｜しかも1台だけのときは、料理が、かえって遅く届いたんです。", 1.8),
 # 1 自己紹介
 ("title", "smile", "Pixels to Motors。案内役のハコブくんです。"),
 ("title", "base", "この番組では、フィジカルAIのニュースや実機の記録から、現場で使える形を考えます。", 1.2),
 ("title_eiichi", "base", "エイイチは、本業のデザイナーをしながら、ロボティクスの研究と開発をしています。｜その中で分かったことと、そこから考えた見立てを、ハコブくんがお伝えします。", 1.8),
 # 2 今日の問い
 ("question", "surprise", "今日の問いは、配膳ロボットを何台入れたら、店は変わるのか、です。", 1.2),
 ("define", "base", "見るのは3つです。お客さんが店に来てから、席に着くまでの時間。料理ができてから、人かロボットが卓に着くまでの時間。｜そして、ホールのスタッフを減らしても、店が回るかどうか。"),
 ("define2", "base", "この試算では、席は空いているのに、片付けや案内を待たせた時間が、平均で10分以上になったら、「店が回らない」と数えます。｜この10分は、エイイチが決めた比べるための線で、実際の店の営業を決める基準ではありません。満席で待つ時間は入れていません。", 1.2),
 ("scope", "base", "話の範囲も言っておきます。これは、パソコンの中の試算です。｜数字の多くは、公開されている導入事例やメーカーの仕様をもとにした仮の値で、実際の店で測ったものではありません。"),
 ("scope", "base", "店は、お客さんが1人もいない状態から始めて、2時間だけお客さんを受け入れ、最後の片付けが終わるまでを計算します。｜比べるのは、試算に入れたホールの仕事を何人でこなせるかで、厨房との兼務や準備、休憩までは入っていません。"),
 ("scope", "base", "そしてこの動画は、シリーズの1回目です。今日は、時間と数だけで計算する試算の話。｜通路でロボットがどこで止まるかは、このあとの回で、3Dの店を建てて調べます。", 1.2),
 # 3 結論と立場
 ("answer", "base", "先に結論を言うと、35卓のファミレスで、スタッフ3人のまま、料理を運ぶロボットを増やすと、席に着くまでの時間は、2台目までで大きく縮み、3台目から先は、大きくは縮みませんでした。"),
 ("answer_two", "think", "そして、普通の混み具合で、料理を運ぶだけなら、試した6台までのどの台数でも、スタッフを3人から2人にすると、店は回りませんでした。｜変わったのは待ち時間で、要る人数ではなかったんです。", 1.2),
 ("stance", "base", "先に言っておくと、配膳ロボットは意味がない、と言いたいわけじゃないんです。"),
 ("stance", "smile", "人を減らさずに足せば、席に着くまでの時間は縮みました。｜そして、店の種類や混み具合によっては、1台入れると、1人少なくても、今回の線を下回りました。ただし、そのときは料理の待ちが延びています。", 1.2),
 # 4 目次
 ("toc", "base", "大きく3つあります。"),
 ("toc", "base", "スタッフ3人のまま、ロボットを増やすと、何が変わるのか。"),
 ("toc", "base", "1台だけだと、なぜ料理が遅くなるのか。"),
 ("toc", "base", "人を減らせる台数は、あるのか。"),
 ("toc", "smile", "特に2つ目は、ロボットにとって、ちょっと気の毒な話です。最後に、エイイチの見立てを話します。", 1.8),
 # 5 節1：3人のまま増やす
 ("c1", "base", "1つ目、スタッフ3人のまま、ロボットを増やすと、何が変わるのか、です。", 1.2),
 ("c1", "base", "一言で言うと、席に着くまでの時間は、2台目までで大きく変わり、そこから先は横ばいです。", 1.2),
 ("setup", "base", "試算の店は、35卓のファミレス。普通の混み具合で、1時間に35組が来ます。｜ロボットは料理を運ぶだけで、デザートやドリンクも含めて、1回の往復で料理を3つまで運びます。"),
 ("simpy", "base", "使ったのは、SimPyという、Pythonの道具です。お客さんが来る、料理ができる、誰かが運ぶ、皿を下げる、といった出来事を時間の順に並べて、どこで待ちが生まれるかを数えます。"),
 ("simpy", "base", "1つの条件を40回計算して、平均を出しています。"),
 ("speed", "base", "ロボットの速さは、仮に秒速0.5メートル。人が歩く速さの半分です。この速さも、実際の店で測った値ではありません。", 1.2),
 ("entry", "base", "まず、お客さんが店に来てから、席に着くまで。ロボットなしで8.6分。1台で4.0分。2台で3.2分。"),
 ("entry_flat", "surprise", "3台で2.9分。そこから先は、4台でも6台でも、3分台です。"),
 ("entry_flat", "base", "4台では3.9分と、3台より延びています。偶然のばらつきかどうかは、この平均だけでは分かりません。｜ここでは、2台目までのような大きな縮み方は、もう見られなかった、と読んでください。", 1.2),
 ("why1", "think", "席に着くまでが縮んだのは、料理を運ぶ仕事がロボットに移って、人の手が、片付けや案内に回ったからです。"),
 ("why1b", "base", "席は空いているのに待たせた時間は、ロボットなしの7.4分から、2台で2.3分まで減りました。"),
 ("why1c", "think", "でも、皿を下げて卓を拭くのも、案内も、会計も、人の仕事のまま。｜今回の作業時間と仕事の順番では、ロボットを増やしても、人の仕事を待つ時間が残りました。"),
 ("util", "base", "6台入れたときに、ロボット1台が仕事でふさがっていた時間は、平均で2割ちょっと。お客さんが料理を取るのを待つ時間も含めて、です。"),
 ("util", "smile", "台数に対して、出番は少なくなりました。ハコブくんなら、ちょっと居心地が悪いです。", 1.2),
 ("walk", "base", "人が歩く距離も見ておきます。お客さんが帰ったあとの片付けまでで、ロボットなしでは1人3.9キロ。2台で2.3キロ。約4割減です。これも、3台目から先は変わりませんでした。"),
 ("c1_end", "base", "席に着くまでの時間と歩く距離が大きく変わったのは、2台目まで。料理を早く届けたいなら、3台目にも効き目がありました。そこは、次の話です。", 1.8),
 # 6 節2：1台だと遅くなる
 ("c2", "base", "2つ目、1台だけだと、なぜ料理が遅くなるのか、です。", 1.2),
 ("dish_ans", "surprise", "料理ができてから卓に着くまで。ロボットなしの58秒が、1台では2分12秒。倍以上に延びました。"),
 ("dish_more", "base", "2台で約1分、3台で39秒。ロボットなしより早く届くようになったのは、3台目からでした。", 1.2),
 ("rule", "base", "理由は、この試算の運び方の決まりにあります。料理ができたら、まずロボットを待つ。2分待ってもロボットが来なければ、人も運べるようにする。"),
 ("rule", "think", "ロボットを入れたのなら、人よりロボットに働いてもらう。エイイチが、そう決めた決まりです。"),
 ("rule2", "base", "ロボットが1台だと、往復している間に、次の料理ができてしまいます。その料理は、受け渡し口でロボットの帰りを待つ。これが積み重なって、平均が延びました。"),
 ("rule2", "smile", "1台のときは、ロボットが仕事でふさがっていた時間が、約4分の3。がんばっているのに、遅いと言われる。ちょっと気の毒です。", 1.2),
 ("fair", "base", "現場なら、手が空いた人が先に運ぶ、というやり方もあります。｜前の版の試算はその決まりで、受け渡し口の近くにいる人が先に運び、ロボットの出番が減っていました。2分待つのがいいか、すぐに人も運ぶのがいいかは、別に比べる必要があります。"),
 ("honne1", "smile", "この結果を見たエイイチの感想は、「意外な結果だなと。でも、いまの配膳ロボットの限界点も、よくわかった」、だそうです。"),
 ("honne1", "base", "ただ、今回分かったのは、この試算の条件での限界です。今の製品の性能を測ったわけではありません。", 1.2),
 ("c2_end", "think", "何台入れるかは、誰が先に運ぶかの決まりと、セットで決める。台数だけを見ても、答えは出ません。", 1.8),
 # 7 節3：人を減らせる台数
 ("c3", "base", "3つ目、人を減らせる台数は、あるのか、です。", 1.2),
 ("c3", "base", "一言で言うと、このファミレスの普通の日に、料理を運ぶだけなら、試した6台までには、ありませんでした。でも、混み具合や店が変わると、答えも変わります。", 1.2),
 ("two", "base", "スタッフを2人にすると、ロボットなしでは、席に着くまで28.3分。"),
 ("two_b", "trouble", "1台で16.9分、2台で14.3分。3台から6台でも、14分から16分のまま。どれも、店が回らない、に入りました。"),
 ("two_c", "think", "運ぶ仕事はロボットが持っていっても、皿を下げて、卓を拭いて、案内するのは、2人のまま。そこが追いつかないんです。", 1.2),
 ("honne2", "base", "昔ファミレスで働いていたエイイチに、ロボットに任せたかった仕事を聞いてみました。"),
 ("honne2_list", "smile", "お客さんが食べ終わった皿を下げること。パントリーに下げた皿を、洗い場まで運ぶこと。｜そして、フォークやスプーンを拭くこと。「地味に大変」だそうです。"),
 ("honne2_c", "base", "どれも、今日の試算のロボットには、任せていない仕事です。"),
 ("patrol", "smile", "実は試算には、食事中に席を回って、お客さんに空いた皿を載せてもらう使い方もあります。｜ロボットが来るたびに、お客さんが一定の量の皿を載せてくれる前提なら、2人とロボット3台で、今回の線を下回りました。この話は、次の回で。", 1.2),
 ("crowd", "base", "同じ店でも、混雑の日は話が変わります。1時間に45組来ると、ロボットなしでは4人要るところが、1台で、3人でも今回の線を下回りました。"),
 ("crowd_note", "think", "ただし、席に着くまでの時間は、4人のときの11.4分から17.4分に、料理の待ちは45秒から2分17秒に延びます。｜同じサービスのまま人を減らせた、という意味ではありません。", 1.2),
 ("other", "base", "では、店が変わるとどうなるか。試算には、ファミレスのほかに、店の種類があと4つあります。｜それぞれの店に、来客数も食事の長さも別々の、普通の混み具合を置いています。店の種類だけを変えた比べではありません。"),
 ("buffet", "surprise", "焼肉の食べ放題の店では、スタッフ2人だと、ロボットなしで約27分半。1台入れると9.8分に、約18分縮んで、今回の線を下回りました。"),
 ("buffet", "base", "ただ、3人でロボットなしの9.2分より少し長く、料理の待ちは1分17秒から3分26秒に延びます。追加の注文が多くて、皿の行き来が多い店です。"),
 ("hotel", "base", "洗い場が遠い店でも、2人とロボット1台で、線を下回りました。ただ、3人のときの2.9分に比べると、6.0分に延びます。"),
 ("large", "base", "60卓の大型店は、普通の混み具合で、6人が5人に。ただし、5人で線を下回るのは、2台からです。", 1.2),
 ("c3_end", "think", "何台で、何が変わるかは、店と混み具合ごとに違う。近い店の数字は、自分の店の答えにはなりません。", 1.8),
 # 8 見立て
 ("mitate", "base", "ここからは、エイイチの見立てです。", 1.2),
 ("mitate1", "think", "ロボットを入れるだけでは、良くならないこともある。そこには、専門家の知見が要る。エイイチは、そのことを知ってほしいと考えています。", 1.2),
 ("mitate2", "base", "今日の比べでも、台数と、任せる仕事で、答えが変わりました。誰が先に運ぶかという決まりも、結果に関わっています。"),
 ("mitate2", "smile", "ハコブくんなりに言うと、まず、店の仕事を知る人と一緒に、運び方を確かめることです。｜その人は、店長やスタッフかもしれないし、メーカーや導入を手伝う人かもしれません。", 1.2),
 ("limits", "base", "ただ、フェアに言っておくと、今日の試算は、時間と数だけの計算です。"),
 ("limits", "base", "ロボットが止まるのは、台数に関係なく、往復の3割で数秒、1%で人が動かしに行く、と仮に置いています。台数が増えて、ロボットどうしが邪魔になることは、まだ入っていません。"),
 ("limits", "base", "料理をロボットに載せて、行き先を決める人の時間も入っていません。｜そして今日の35卓の店は、卓までの距離を幅で置いただけで、図面は使っていません。", 1.2),
 ("next", "smile", "だから次の回からは、そこを調べていきます。まず、人は減らせるのか、月いくら浮くのか。｜そのあと、店の図面から3Dの店を建てて、ロボットを走らせます。実際の店にどこまで合うかは、店での観察とも照らしていきます。", 1.8),
 # 9 問いへの回答
 ("answer2", "base", "じゃあ、冒頭の問いに答えますね。配膳ロボットを何台入れたら、店は変わるのか。"),
 ("answer2", "think", "35卓のファミレスで、スタッフ3人のまま増やすと、2台までで、席に着くまでの時間が8.6分から3.2分に縮み、3台目から先は、大きくは縮みませんでした。"),
 ("answer2b", "base", "1台では、ロボットを待つ決まりのため、料理はかえって遅く届きました。｜そして、普通の混み具合で料理を運ぶだけなら、試した6台まで、3人を2人にすると、店は回りませんでした。"),
 ("answer2b", "base", "混雑の日や、焼肉の食べ放題、洗い場が遠い店では、1台で、1人少なくても線を下回りました。ただし、待ち時間は延びています。｜何台で変わるかは、店と混み具合と、決まりで変わります。", 1.2),
 ("question_next", "smile", "次に、配膳ロボットのいる店に行ったら、数えてみてください。何台いて、料理を運んでいるのは、ロボットか、人か。", 1.2),
 ("try", "smile", "この試算は、サイトのシリーズのページで、台数や人数を変えて試せます。リンクは、概要欄に置いておきます。", 1.8),
 # 10 アウトロ
 ("outro", "smile", "この動画が役に立ったら、高評価とチャンネル登録をお願いします。"),
 ("outro", "base", "ロボットの開発や導入の相談は、概要欄の連絡先から、エイイチに直接どうぞ。"),
 ("outro", "wink", "コメントは、エイイチが全部読んでいます。それではまた。", 1.2),
]

# 「エイイチ」を残す文（自己紹介・目次の最後・見立ての入り・アウトロの2文のほかに、名前が要る文）。文の頭で探す（video_rules.md 3-1）
NAME_OK = [i + 1 for i, seg in enumerate(SEGS) if seg[2].startswith((
    "エイイチが見かけた店も",
    "この試算では、席は空いているのに、片付けや案内を",
    "ロボットを入れたのなら、人よりロボットに",
    "この結果を見たエイイチの感想は",
    "昔ファミレスで働いていたエイイチに",
    "ロボットを入れるだけでは、良くならないこともある",
))]

# 読み上げ用の読み（仮音声 Open JTalk 用。字幕は元の表記のまま）
READ = [
 ("Pixels to Motors", "ピクセルズ トゥー モーターズ"),
 ("YouTube", "ユーチューブ"),
 ("SimPy", "シムパイ"),
 ("Python", "パイソン"),
 ("Unity", "ユニティ"),
 ("フィジカルAI", "フィジカルエーアイ"),
 ("AI", "エーアイ"),
 ("1人", "ひとり"), ("2人", "ふたり"), ("1つ", "ひとつ"), ("2つ", "ふたつ"), ("3つ", "みっつ"),
 ("2,100", "にせんひゃく"), ("3,000", "さんぜん"),
 ("栄一", "えいいち"),
 ("「", ""), ("」", ""), ("『", ""), ("』", ""), ("｜", ""), ("…", ""),
]

# ElevenLabs の声で読み間違えた語だけ、読みを直す（tts_eleven.py が使う。字幕は変えない）
ELEVEN_READ = [
    ('エイイチ', '栄一'),   # video_rules.md 3-1（2026-10-12 決定）
    ('額だけ', 'がくだけ'),
    ('裏表', 'うらおもて'),
    ('R2R2R', 'リアルツーレンダーツーリアル'),
    ('SIer', 'エスアイヤー'),
    ('ロボットの側', 'ロボットのがわ'),
    ('4コママンガ', 'よんコマまんが'),
    ('OpenAI', 'オープンエーアイ'),
    ('Claude Code', 'クロード コード'),
    ('Claude', 'クロード'),
    ('描く', 'えがく'), ('描き', 'えがき'),
    ('物差し', 'ものさし'), ('力制御', 'ちからせいぎょ'), ('人型', 'ひとがた'),
    ('Mac', 'マック'),
    ('SimPy', 'シムパイ'),
    ('Python', 'パイソン'),
    ('Unity', 'ユニティ'),
    # この回で先回りして足す読み（--try で確かめる）
    ('1.4台', 'いってんよんだい'),
    ('2分12秒', 'にふんじゅうにびょう'),
    ('3分台', 'さんぷんだい'),
]

# 間延び対策：ひと息が長い文だけ、読み上げ用に読点を足す（字幕は変えない）
OVERRIDE = {
 "エイイチは、本業のデザイナーをしながら、ロボティクスの研究と開発をしています。｜その中で分かったことと、そこから考えた見立てを、ハコブくんがお伝えします。":
   "エイイチは、本業のデザイナーをしながら、ロボティクスの研究と開発をしています。その中で分かったことと、そこから考えた見立てを、ハコブくんが、お伝えします。",
}


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


def parse(seg):
    """1行を (場面, 表情, 字幕, 間) にそろえる。表情の欄がない古い形 (場面, 字幕[, 間]) も読む"""
    if len(seg) >= 3 and seg[1] in FACES:
        scene, face, text = seg[0], seg[1], seg[2]
        pause = seg[3] if len(seg) > 3 else 0.35
    else:
        scene, face, text = seg[0], "base", seg[1]
        pause = seg[2] if len(seg) > 2 else 0.35
    return scene, face, text, pause
```

### 動画/何台入れたら店は変わる/codex_検証結果_1回目.md

```markdown
# codex 敵対的検証 結果 1回目（何台入れたら店は変わる）

- 日時: 2026-10-24 18:50（JST）
- 依頼文: codex_敵対的検証依頼_何台入れたら店は変わる.md（md5 2e9afb7a8b0d4f49a7afe221c04e9ca4・25,339 バイト）
- 題（依頼文の1行目）: # 敵対的検証依頼：YouTube台本「配膳ロボット、何台入れたら店は変わる？ ― ファミレスの混む2時間を、0台から6台まで試算した」
- codex: codex-cli 0.163.0-alpha.11
- モデル: gpt-6.1-sol
- 考える深さ: high
- ウェブ検索: あり（検索した回数 8）
- かかった時間: 15分15秒
- 使ったトークン: 181,214
- 指摘の数: 27（うち出典のリンクが付いたもの 1）
- 出典の URL: 1件（別々・文の中に張られた回数 1）

---
判定は **「直してから」** です。主な問題は数字の転記よりも、**独自の判定基準を通ったことを「人を減らして店が回る」と表現している点**と、**仮の運用ルールの結果を実機の限界に結びつけている点**です。

確認範囲を明記します。

- 公開中の試算ページ・シリーズページ・LOG351／359／367は、ウェブから取得できませんでした。**公開画面で条件を選んだ照合は未確認**です。
- 関連フォルダの [v2.7試算ページのローカル版](</Users/shimazueiichi/Library/Mobile Documents/com~apple~CloudDocs/デスクトップ/ADAPT DESIGN/じぶんの/PixelstoMotors/デジタルツイン学習R2R2R/サービスロボット導入シミュレーション/simulation/v2_7/serving-robot.v27.html>)、[v2.7コード](</Users/shimazueiichi/Library/Mobile Documents/com~apple~CloudDocs/デスクトップ/ADAPT DESIGN/じぶんの/PixelstoMotors/デジタルツイン学習R2R2R/サービスロボット導入シミュレーション/simulation/v2_7/haizen_sim.py>)、保存結果、記事原稿を読みました。以下の「試算ページ」「コード」は、このローカル資料を指します。公開版との同一性は未確認です。
- 主要な数値はローカル資料と一致しました。S064には後述する表示上の丸めの差があります。SimPyが実行環境にないため、**今回の再計算と統計的なばらつきの確認は未実施**です。
- すかいらーくの導入実績は公式サイトでも確認できました。

---

**[❌ NG] S018・S062・S064・S066〜S067・S079**

問題：「1人少なくても回る」は、サービス水準を保って人を減らせたように聞こえます。実際には、独自の入店待ち基準を通っただけで、料理の待ち時間などは悪化しています。

根拠：試算ページで、人数を減らす前後を比較すると次のとおりです。

| 条件変更 | 入店待ち | 料理が届くまで |
|---|---:|---:|
| 混雑ファミレス：4人・0台→3人・1台 | 11.4→17.4分 | 45秒→2分17秒 |
| 食べ放題：3人・0台→2人・1台 | 9.2→9.8分 | 1分17秒→3分26秒 |
| 洗い場が遠い店：3人・0台→2人・1台 | 2.9→6.0分 | 1分06秒→2分22秒 |
| 大型店：6人・0台→5人・2台 | 9.7→7.4分 | 57秒→2分25秒 |

さらに食べ放題の2人・1台では、完成から到着まで3分を超える料理が約55%あります。「回る」の判定は、これらを条件にしていません。

修正案：「店によっては、1人少なくしても、今回の入店待ちの基準は通りました。ただし、料理が遅くなる条件もあります。同じサービスを保って人を減らせた、という意味ではありません。」

---

**[❌ NG] S051**

問題：本人の感想として引用していても、「いまの配膳ロボットの限界点」が今回の結果で分かった、という受け取りになります。今回確認したのは、仮のロボットと運用ルールを組み合わせたモデルの挙動です。

根拠：料理を2分待たせるルール、秒速0.5m、作業時間などを設定した結果であり、現行製品の性能限界を測定していません。本人の感想の真偽ではなく、引用の置き方の問題です。

修正案：「エイイチは、『いまの配膳ロボットの限界点も、よくわかった』と感じたそうです。ただ、今回確かめたのは、この試算の条件での限界です。実機全体の限界が分かったわけではありません。」

---

**[❌ NG] S032**

問題：「ぶれの範囲と見ています」に続く「変わらない、と見てください」は、根拠を示さず増加を打ち消しています。40回平均という情報だけでは、ぶれかどうかも、実用上同等かどうかも判断できません。

根拠：3台2.906分に対し4台3.897分で、差は約1分、約34%です。確認した保存結果は平均値で、反復ごとの分散や差の信頼区間は確認できませんでした。同じコード・同じ乱数で再現しても、この統計的な問題は解消しません。

修正案：「4台では3.9分と、3台より延びています。偶然のばらつきかどうかは、この平均値だけでは判断できません。ここでは、2台目までのような大きな短縮は見られなかった、と読んでください。」

---

**[❌ NG] S013・S027〜S029・S073〜S074**

問題：省略している前提のうち、人員の結論に直接効くものが説明されていません。特に、料理をロボットに積み込み、行き先を設定するためのスタッフの時間が計上されていません。

根拠：コードの `deliver()` から `_robot_run()` では、スタッフを確保せず、待っている料理をまとめて出発させています。`load_s` は下げ膳への積み込みで使われ、上げ膳には使われません。したがって、ロボットを導入すると運搬の仕事は減りますが、その準備に必要な人の仕事が追加されないモデルです。

修正案：「もう一つ、料理をロボットに載せて、行き先を設定する人の時間は、今回の計算に入れていません。人を減らせるかを見るには、ここも実際の店に合わせて確かめる必要があります。」

---

**[❌ NG] S036〜S037・S049**

問題：「動いていた割合」「出ずっぱり」は、計算している指標と違います。「残りは受け渡し口の前」「6台が並ぶ」も、位置を計算していない試算からは確認できません。

根拠：`robot_util` は、全処理が終わるまでの時間に対する、1台平均の仕事でふさがっていた時間です。走行のほか、客の取り出し待ち25秒、一時停止、救出待ちなどを含みます。6台平均22.5%から、6台が同時に並んで待つ様子は分かりません。

修正案：「6台では、1台あたり、仕事でふさがっていた時間は平均で約23%。走る時間だけでなく、お客さんが料理を取るのを待つ時間なども含みます。台数に対して、出番は少なくなりました。」

S049は「1台では、この割合が約75%でした」に変更。

---

**[⚠️ 要注意] S012**

問題：「席は空いているのに」と「満席」の区分は、実際の着席状況をそのまま再現したものではありません。この区分が、人員の合否を決める中心指標になっています。

根拠：コードでは、客が案内される前に卓を確保します。その卓は、案内待ちの間も使用中として扱われます。後続客の待ちが「満席」側に入る場合があり、物理的に客が座っているかとは一致しません。判定への影響量は未確認です。

修正案：「このモデルで、片付け待ちと、案内する人の手が空くまでの待ちを足して、平均10分以上なら『店が回らない』と呼びます。実際の店の営業可否を決める基準ではありません。」

---

**[⚠️ 要注意] S015・S025・S039・S077**

問題：「2台目まで」という境目が、35卓という店の大きさから決まるように聞こえます。また、席待ちの改善が小さいことから、導入すべき台数までは決まりません。

根拠：対象は35組/時、スタッフ3人、料理を運ぶだけ、ロボット優先2分という条件です。料理の到着は2台約62秒から3台約39秒へ短縮しており、何を改善したいかで台数の評価が変わります。

修正案：「今回の35卓・35組毎時・スタッフ3人で、料理を運ぶだけの条件では、席待ちの大きな改善は2台目まででした。料理を早く届けたい場合は、3台目にも効果が出ています。」

---

**[⚠️ 要注意] S016・S054・S078**

問題：「何台入れても」は、試した範囲を超えています。「このファミレスの普通の日には、ありませんでした」は、後で紹介する巡回回収とも衝突します。

根拠：確認したのは0〜6台です。また、同じ店・同じ来客ペースでも、巡回回収なら2人・3台で判定を通っています。

修正案：「このファミレスで、普通の混み具合、料理を運ぶだけなら、試した0台から6台の範囲では、2人で基準を通る条件はありませんでした。」

---

**[⚠️ 要注意] S063〜S066**

問題：「普通の混み具合で比べます」では、来客数などをそろえた業態比較に聞こえます。実際には、業態以外の条件もまとめて変えています。

根拠：ファミレス35組/時、食べ放題20組/時、洗い場が遠い店22組/時、大型店60組/時。食事時間や追加注文数も異なります。食べ放題は来客を発生させる時間も150分で、ほかの120分と違います。

修正案：「ここからは、それぞれの店に別々に置いた『普通』の条件です。来客数も、食事の長さも違います。食べ放題だけは、混む時間を2時間半にしています。店の種類だけを変えた比較ではありません。」

---

**[⚠️ 要注意] S064**

問題：「9.8分で、ぎりぎり」は、入店待ち9.8分を10分の判定線と比べたように聞こえます。判定に使う数字は別です。

根拠：入店待ちは9.803分ですが、判定対象の「席はあるのに」は9.191分です。また、40回平均が線を下回っても、各回で安定して通るかは未確認です。

修正案：「1台では、入店待ちが9.8分に。判定に使う片付け・案内の待ちは9.2分で、今回の10分の線を下回りました。ただし、毎回安定して通るかまでは、この平均だけでは分かりません。」

---

**[⚠️ 要注意] S061**

問題：客が皿を載せることが、結果を左右する前提なのに省略されています。ロボットの使い方を変えるだけで、自動的に2人で回るように聞こえます。

根拠：コードでは回収が完了するたびに、最後の下げ膳の往復を1回減らします。客が載せない、十分な量が集まらない、といった場合は表現していません。

修正案：「この使い方では、ロボットが回収に来るたび、お客さんが一定量の皿を載せてくれる前提です。その条件なら、2人と3台で、今回の基準を通りました。」

---

**[⚠️ 要注意] S033〜S035・S057**

問題：残った待ちの原因を、片付けと案内だけに絞りすぎています。「人の手の速さで決まります」も、作業時間、優先順位、来客の集中などを一括してしまっています。

根拠：コードではスタッフが案内・配膳・会計・呼び出し・片付けを共通で担当し、片付けの優先順位は案内・配膳・会計より低く設定されています。作業別に条件を変えた検証は今回確認できていません。

修正案：「料理の運搬を任せても、片付けや案内、会計などは人に残ります。今回の作業時間と仕事の優先順位では、人の仕事を待つ時間が残りました。」

---

**[⚠️ 要注意] S011・S016・S054・S073**

問題：「ホールのスタッフを減らせるか」という問いに対し、実際の勤務体制を決める仕事がモデルの外にあります。

根拠：スタッフは全員が同じ能力で各仕事を引き受ける共通資源です。担当分け、厨房との兼務、休憩、ピーク前後の準備、清掃などは表現していません。調理にも人数や設備の取り合いがなく、設定した時間で料理ができます。

修正案：「ここで比べるのは、モデルに入れたホールの仕事を、何人で処理できたかです。厨房との兼務や準備、休憩まで含めた、その店の必要人数ではありません。」

---

**[⚠️ 要注意] S046〜S050・S052**

問題：「現場なら空いている人が運べばよい」という反論に、説明はありますが検証結果はありません。「ロボットの出番が減る」は、その運用が悪い理由にはなりません。

根拠：ロボットの稼働割合は、待ち時間や人の負担とは別の指標です。同じv2.7のほかの条件を固定して、優先待ち0秒・30秒・120秒などを比較した結果は未確認です。

修正案：「現場なら、空いた人が先に運ぶ運用も考えられます。今回は、ロボットを優先して使う決まりを試した結果です。2分待つのがよいか、すぐ人も運ぶ方がよいかは、別に比べる必要があります。」

---

**[⚠️ 要注意] S071**

問題：「誰が先に運ぶかで、答えが変わりました」は、この回で運用ルールをそろえて比較した結果まで示したように聞こえます。

根拠：この回の数値比較はv2.7内の台数・人数・使い方が中心です。前の版の説明はありますが、同条件でルールだけを変えた定量比較は提示していません。

修正案：「今日の比較では、台数と任せる仕事で答えが変わりました。そして、誰が先に運ぶかという決まりも、結果に関わっています。」

---

**[⚠️ 要注意] S011・S042〜S045**

問題：「料理が席に届くまで」の終点が、視聴者の想像とずれる可能性があります。

根拠：コードは卓への到着時点で料理の待ち時間を記録し、その後にロボット25秒、人10秒の受け渡し時間を置きます。客が取り終わるまでの時間ではありません。

修正案：「料理の時間は、できあがってから、人かロボットが卓に着くまでです。そこから料理を取り出す時間は含めていません。」

---

**[⚠️ 要注意] S026・S081**

問題：台本の数字を再現するための条件が一つ抜けています。また、「最大3卓」は、コードのまとめ運びの単位を正確には説明していません。

根拠：該当行は `fragile_by="any"`、つまりデザート・ドリンクもロボットが運ぶ設定です。まとめ運びは最大3件の料理で、異なる3卓であることを保証していません。

修正案：「ロボットは料理を運ぶだけ。デザートやドリンクも任せ、待っている料理を一度に最大3件まとめて運ぶ設定です。」

---

**[⚠️ 要注意] S005・S028・S038**

問題：「ファミレスの混む2時間を再現」は、ピーク開始時点ですでに客がいる店を想像させます。今回の計算は空の店から始まります。

根拠：コードは初期客なしで開始し、120分で新規来客を止め、処理が終わるまで回します。歩く距離は客の退店後の片付けまで含むため、「送り出すまで」より範囲が広いです。

修正案：「空の店に2時間だけお客さんが来て、最後の片付けまで計算しています。すでに混んでいる店の2時間を切り取ったものではありません。」

S038は「そのお客さんが帰ったあとの片付けまでに」に変更。

---

**[⚠️ 要注意] S074**

問題：一時停止を仮に入れている説明だけでは、台数を増やしたときの混雑を扱っているようにも聞こえます。

根拠：コードの停止確率は各往復で固定です。ロボットが増えたことで互いを妨げる仕組みはありません。試算ページ自体にも、ロボット同士の通路待ちを入れていない旨の注意があります。

修正案：「停止は、台数に関係なく、往復ごとに同じ確率で入れています。台数を増やしたせいでロボット同士が邪魔になることは、まだ計算していません。」

---

**[⚠️ 要注意] S075**

問題：「3Dで走らせて確かめる」だけでは、現実の妥当性まで確かめられるように聞こえます。

根拠：3D上の動きも、人やロボットの動作ルールに依存します。SimPyとUnityの一致は、現場で同じことが起こる証明にはなりません。

修正案：「そのあと、図面から3Dの店を建てて、通路や動きの影響を調べます。実際の店にどこまで合うかは、現場の観察や実測とも照らしていきます。」

---

**[⚠️ 要注意] S070〜S072・S083**

問題：最も強い反論は、「仮の運用で問題を作って、その結果から専門家の必要性を説いていないか」です。相談窓口への誘導が続くため、宣伝として読まれる余地があります。

根拠：今回の試算には、専門家が関与した場合としなかった場合の比較がありません。「仕事の流れを調べる必要がある」から「専門家が必須」までは導けません。必要な知見を持つ人が、店長や現場スタッフである可能性もあります。

修正案：本人の見立ては残し、S072を変更。
「ハコブくんなりに言うと、まず、店の仕事を知る人と一緒に、運び方を確かめることです。その人は、店長やスタッフかもしれないし、メーカーや導入支援の人かもしれません。」

---

**[⚠️ 要注意] S065〜S066**

問題：「1台の効き目がいちばん大きい」は、何を比べた順位なのか不明です。また、洗い場が遠い店で効いたことから、洗い場の遠さが原因だとは言えません。

根拠：通常表示の人数で入店待ちの短縮量を見ると、大型店は約8.7分、食べ放題は約5.5分です。一方、S064のスタッフ2人での比較なら食べ放題は約17.6分短縮します。「洗い場が遠い店」には追加注文など別の違いもあり、ここでロボットに任せているのは上げ膳です。

修正案：「この食べ放題の設定では、スタッフ2人のまま1台足すと、入店待ちが約18分短くなりました。洗い場が遠い設定の店でも、料理の運搬を任せることで、今回の基準を通りました。」

---

**[⚠️ 要注意] S018**

問題：「人を減らさずに足せば、待ち時間ははっきり縮みます」は、「待ち時間」全体への断定です。直後の料理の悪化と矛盾して聞こえます。

根拠：この条件で1台導入すると、入店待ちは短縮しますが料理の到着は遅くなります。台数を追加するたびに改善する結果でもありません。

修正案：「今日の条件では、人を減らさずに1台、2台と足すと、席に着くまでの時間は短くなりました。一方、料理の待ちは、1台では延びています。」

---

**[⚠️ 要注意] S064**

問題：公開ページと同じ表示値を確かめるという点では、27.5分に小さな不一致があります。

根拠：保存結果は27.4502分なので、直接小数1桁に丸めれば27.5分です。しかしローカルHTMLには27.450として収録され、ページのJavaScriptの `toFixed(1)` では27.4分になります。転記ミスというより、表示までの丸めの問題です。公開画面は未確認です。

修正案：「ロボットなしでは、約27分半。」  
または、ページ側の表示精度と台本を統一。

---

**[⚠️ 要注意] S002**

問題：1.4台という計算は成立しますが、導入店舗平均であり、典型的な店舗の台数や適正台数を示す数字ではありません。

根拠：公式の沿革に、2022年12月、約2,100店へ3,000台の導入完了とあります。3,000÷2,100≒1.43です。店舗別の分布は示されていません。提示された2022年2月の開示資料は計画なので、実績の出典は公式沿革に替える方が明確です。[すかいらーく公式「歴史・沿革」](https://corp.skylark.co.jp/about/history/)

修正案：「導入した約2,100店で単純に割ると、1店平均で約1.4台です。店ごとの台数や、ちょうどよい台数が分かる数字ではありません。」

---

**[⚠️ 要注意] S029**

問題：「急がないのも、配膳ロボットの仕事のうち」は、仮に置いた速度を、実機の安全性や設計意図で裏付けたように聞かせます。

根拠：この試算では速度を固定しており、安全性や混雑に応じた速度調整を評価していません。店や会社を笑う表現ではありませんが、前提の説明としては曖昧になります。

修正案：「ロボットは仮に秒速0.5メートル、人は秒速1メートルとしました。この速さも、実際の店で測った値ではありません。」

---

**[⚠️ 要注意] S019〜S025・S040〜S043・S053〜S054**

問題：結論の先出し、目次、節タイトル、「一言で言うと」が重なっています。特に料理が遅くなる話は、答えを何度か明かしたあとにクイズを置いています。

根拠：S006・S021・S023・S040・S041のあとでS043の問いかけが来るため、意外性を作る構成として機能しにくくなっています。必要な但し書きを増やすなら、ここが削減候補です。

修正案：S019〜S023を次の一文に置換。
「まず、スタッフ3人のまま台数を増やした結果から見ます。」

S040〜S043は次に圧縮。
「ところが、料理の待ちは別でした。ロボットなしの58秒が、1台では2分12秒に延びたんです。」

---

最後の判定です。

1. **このまま公開してよいか：直してから。**  
   構成全体を作り直す必要はありません。ただし、「回る」「限界」「ぶれ」「動いていた割合」の意味は修正が必要です。

2. **公開前に必ず直すべき文番号：**  
   **S012〜S013、S015〜S016、S018、S032、S036〜S037、S049、S051、S054、S061〜S064、S066〜S067、S070〜S075、S077〜S079。**  
   とくに、人員削減の文には「今回の入店待ち基準を通った」という限定と、料理の待ちが悪化する事実を戻してください。

3. **台本の結論そのもの：条件つきで成立。**  
   「このモデルでは、席待ちと歩行距離の大きな改善が2台目まで」「台数・仕事・運用ルールで結果が変わる」は支持されます。一方、**実店舗の必要人数、現行ロボットの性能限界、専門家の必須性までは、この試算からは成立しません。** また、「3台目以降は同等」とする統計的な裏付けと、公開中の試算画面との一致は未確認です。
```

### 動画/何台入れたら店は変わる/概要欄.txt

```text
ファミレスで見かける配膳ロボット。すかいらーくグループは、2022年12月に約2,100店へ約3,000台の導入を終えました（単純に割ると1店あたり平均で約1.4台）。では、ロボットを増やせば増やすほど、店は良くなるのでしょうか。ファミレスの混む時間帯の2時間をパソコンの中で再現して、ロボットを0台から6台まで1台ずつ増やしてみました。

今日の問い：配膳ロボットを何台入れたら、店は変わるのか

先に答え：35卓のファミレスで、スタッフ3人のまま増やすと、お客さんが席に着くまでの時間は2台目までで大きく縮み（8.6分→3.2分）、3台目から先はほぼ横ばいでした。1台だけのときは、料理がロボットを待つ決まりのため、料理はかえって遅く届きました。普通の混み具合で料理を運ぶだけなら、試した6台までのどの台数でも、スタッフを3人から2人にすると店は回りませんでした。ただし、混雑の日や焼肉の食べ放題、洗い場が遠い店では、1台で1人少なくても今回の線を下回りました（待ち時間は延びます）。

この動画で見る3つ
・スタッフ3人のまま、ロボットを増やすと、何が変わるのか
・1台だけだと、なぜ料理が遅くなるのか
・人を減らせる台数は、あるのか

試算はサイトで、店・人数・台数・使い方を変えて試せます。
https://pixelstomotors.com/series8

▼目次
0:00 配膳ロボット、店に何台いた？
0:53 この番組について
1:17 今日の問い：何台入れたら、店は変わるのか
2:40 先に結論
3:53 1. スタッフ3人のまま、ロボットを増やすと
6:36 2. 1台だけだと、なぜ料理が遅くなるのか
8:25 3. 人を減らせる台数は、あるのか
9:10 元ファミレスのエイイチが、ロボットに任せたかった仕事
10:17 店が変わると、答えも変わる
11:29 エイイチの見立て
12:06 この試算でまだ分からないこと
12:57 冒頭の問いへの答え
14:04 エンディング

▼この動画で扱う範囲
・パソコンの中の試算です（Python の SimPy で、お客さんが来てから帰るまでの出来事を時間の順に再現）。数字の多くは、公開されている導入事例やメーカーの仕様をもとにした仮の値で、実際の店で測ったものではありません。ロボットの速さ（秒速0.5メートル）も仮の値です
・店は空の状態から始め、混む時間帯の2時間だけお客さんを受け入れ、最後の片付けが終わるまでを計算しています。どの条件も40回回した平均です
・「店が回らない」は、席は空いているのに片付けや案内を待たせた時間が平均10分以上の条件です。この10分は、比べるためにエイイチが決めた線です。「回る」は、この線を下回ったという意味で、同じ待ち時間のまま人を減らせたという意味ではありません
・料理はまずロボットを待ち、2分たってもロボットが来なければ人も運べる、という決まりで計算しています（試算 v2.7）
・比べているのは、混む時間のホールの仕事を何人でこなせるかだけです。兼務・準備・休憩、シフト全体で人を減らせるか、費用に見合うかは見ていません
・ロボットが止まるのは、台数に関係なく、往復の3割で数秒、1%で人が動かしに行く、と仮に置いています。ロボットどうしが通路で邪魔し合う時間と、料理をロボットに載せて行き先を決める人の時間は、まだ入っていません
・店ごとの「普通の混み具合」は、それぞれ別の設定です。近い店の数字は、自分の店の答えにはなりません

▼出典
・すかいらーくグループの配膳ロボット（2022年12月 約2,100店に3,000台の導入完了）：すかいらーくホールディングス「歴史・沿革」 https://corp.skylark.co.jp/about/history/
・試算の数字：Pixels to Motors「サービスロボット導入シミュレーション」試作 v2.7 https://pixelstomotors.com/series8

▼関連する記事（作業ログ）
・試算を作った回：https://pixelstomotors.com/record/serving-robot-simulation-simpy
・試算を点検した回：https://pixelstomotors.com/record/audit-simulator-before-unity
・Unity にファミレスを建てた回：https://pixelstomotors.com/record/unity-family-restaurant-serving-robot

▼この番組について
「フィジカルAI解説｜Pixels to Motors」は、フィジカルAIのニュースや実機の記録から、現場で使える形を考える番組です。
エイイチは、本業のデザイナーをしながら、ロボティクスの研究と開発をしています。その中で分かったことと、そこから考えた見立てを、案内役のハコブくんがお伝えします。
この動画は、シリーズ「配膳ロボット導入シミュレーション」の1回目です。次の回からは、人は減らせるのか・月いくら浮くのか、店の図面、Unity で建てた3Dの店へと進みます。

・ナレーションは AI 音声（ElevenLabs）です。話しているのは Pixels to Motors のキャラクター、ハコブくんです
・BGM は ElevenLabs Music で作った曲、効果音はコードで作った音です
・画面の映像は、サイトの試算ページを操作した録画と、Unity で作ったファミレスの映像です。Unity の店はシリーズの後の回で扱います

▼ロボットの開発・導入のご相談
メール：pixelstomotors@adaptdesign.jp

▼リンク
Webサイト：https://pixelstomotors.com
GitHub：https://github.com/PixelstoMotors
X：https://x.com/adaptdesignjp
LinkedIn：https://www.linkedin.com/in/eiichi-shimazu

チャンネル登録・高評価もよろしくお願いします。コメントは、エイイチが全部読んでいます。

#フィジカルAI #配膳ロボット #サービスロボット
```

### r2r2r-content/tools/sync_youtube_shorts.py（本編も読むように直したあとの全文）

```python
#!/usr/bin/env python3
"""sync_youtube_shorts.py — YouTube で公開予約したショートと本編の X 投稿を、公開の時刻に合わせて予定表に入れる。

元になるもの:
  sns/SHORT_ep{N}_sns_posts.json   ショートの X の文（posts.x_template。{url} がショートの URL に替わる）
  sns/VIDEO_{名前}_sns_posts.json  本編の X の文（2026-10-24 から。{url} が https://youtu.be/{id} に替わる）。
                                   動画との結びつけは、タイトルが meta.youtube_title と同じもの、
                                   なければ meta.title_match の語をすべて含み、長さが meta.seconds と3秒以内のもの
                                   （YouTube に上げるときにタイトルを直しても結びつくように）
  youtube_videos.json              エイイチの Mac が1時間ごとに書き出す自分のチャンネルの一覧
                                   （_制作ベース/youtube_schedule.py。説明文の manga/ep{N} から manga_ep を拾う）

やること:
  1. ショートは manga_ep（なければタイトル）、本編はタイトル（なければ語と長さ）で、json と YouTube の動画を結びつける
  2. 公開予約（または公開済み）の時刻を X の予約の時刻にする。同じ時刻に別の X の予約があれば、
     19:30 なら 20:00、それ以外は30分ずつ後ろへずらす（2026-10-24 エイイチの指示「19:30が重なったら動画は20:00」）
     12:30 の枠（2026-10-25 に足した）は「それ以外」に入り、13:00 になる
  3. まだ投稿していない予約は、YouTube 側で時刻を変えていたら合わせて直す
  4. 公開の時刻を過ぎていて、まだ予定表にないものは入れない（古いものを今さら出さない）
LinkedIn には入れない（動画は LinkedIn に直接アップロードする決まり。video_rules.md の 0節の22）。

使い方: python3 tools/sync_youtube_shorts.py --videos youtube_videos.json [--dry-run]
"""
import argparse, glob, json, os, re, sys
from datetime import datetime, timedelta, timezone

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
JST = timezone(timedelta(hours=9))
SCHEDULE = os.path.join(ROOT, "sns", "schedule.json")
POSTED = os.path.join(ROOT, "sns", "posted.json")


def norm(t):
    return re.sub(r"\s+", "", re.split(r"\s#", t or "")[0])


def load(p, d):
    try:
        return json.load(open(p, encoding="utf-8"))
    except FileNotFoundError:
        return d


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--videos", required=True)
    ap.add_argument("--dry-run", action="store_true")
    a = ap.parse_args()
    allv = load(a.videos, {}).get("動画", [])
    vids = [v for v in allv if v.get("is_short")]
    mains = [v for v in allv if not v.get("is_short")]
    sched = load(SCHEDULE, {"posts": []})
    posted = load(POSTED, {})
    done_ids = set(posted.keys()) if isinstance(posted, dict) else {p.get("id") for p in posted.get("posts", posted) if isinstance(p, dict)}
    now = datetime.now(timezone.utc)
    changes = []
    paths = sorted(glob.glob(os.path.join(ROOT, "sns", "SHORT_ep*_sns_posts.json"))) + \
        sorted(glob.glob(os.path.join(ROOT, "sns", "VIDEO_*_sns_posts.json")))
    for path in paths:
        src = json.load(open(path, encoding="utf-8"))
        meta, posts = src["meta"], src["posts"]
        is_main = os.path.basename(path).startswith("VIDEO_")
        if is_main:
            words = meta.get("title_match") or []
            cand = [v for v in mains if norm(v["title"]) == norm(meta.get("youtube_title"))] or \
                [v for v in mains if words and all(w in v["title"] for w in words)
                 and meta.get("seconds") and v.get("seconds") and abs(v["seconds"] - meta["seconds"]) <= 3]
            sid = os.path.basename(path)[:-len("_sns_posts.json")] + "-x"
        else:
            ep = meta["episode"]
            cand = [v for v in vids if v.get("manga_ep") == ep] or [v for v in vids if norm(v["title"]) == norm(meta.get("youtube_title"))]
            sid = f"SHORT_ep{ep}-x"
        cand = [v for v in cand if v.get("publishAt") or v.get("published")]
        if not cand:
            continue
        v = sorted(cand, key=lambda v: v.get("publishAt") or v.get("published"))[-1]
        when = datetime.fromisoformat((v.get("publishAt") or v.get("published")).replace("Z", "+00:00"))
        entry = next((e for e in sched["posts"] if e["id"] == sid), None)
        if entry and sid in done_ids:
            continue
        if not entry and when < now:
            continue
        url = f"https://youtu.be/{v['id']}" if is_main else f"https://youtube.com/shorts/{v['id']}"
        text = posts["x_template"].replace("{url}", url)
        if posts.get("x") != text or meta.get("url") != url or meta.get("youtube_id") != v["id"]:
            posts["x"], meta["url"], meta["youtube_id"] = text, url, v["id"]
            if not a.dry_run:
                with open(path, "w", encoding="utf-8") as f:
                    json.dump(src, f, ensure_ascii=False, indent=2); f.write("\n")
        # 同じ時刻の X の予約を避ける
        taken = {e["post_at"] for e in sched["posts"] if e["platform"] == "x" and e["id"] != sid}
        t = when.astimezone(JST)
        while t.isoformat() in taken:
            t = t.replace(hour=20, minute=0) if (t.hour, t.minute) == (19, 30) else t + timedelta(minutes=30)
        post_at = t.isoformat()
        if entry is None:
            sched["posts"].append({"id": sid, "source": os.path.relpath(path, ROOT), "platform": "x", "post_at": post_at})
            changes.append(f"足した {sid} {post_at} {v['title'][:30]}")
        elif entry["post_at"] != post_at:
            changes.append(f"時刻を直した {sid} {entry['post_at']} → {post_at}")
            entry["post_at"] = post_at
    sched["posts"].sort(key=lambda e: (e["post_at"], e["id"]))
    if changes and not a.dry_run:
        with open(SCHEDULE, "w", encoding="utf-8") as f:
            json.dump(sched, f, ensure_ascii=False, indent=2); f.write("\n")
    print("\n".join(changes) or "変わったものはありません")
    return 0


if __name__ == "__main__":
    sys.exit(main())
```

### r2r2r-content/sns/VIDEO_serving-robots-how-many_sns_posts.json

```json
{
  "meta": {
    "kind": "youtube_video",
    "title": "何台入れたら店は変わる（配膳ロボット導入シミュレーション 第1回）",
    "youtube_title": "配膳ロボット何台で店は変わる？ 2台まで変わり、人は減らせなかった【導入シミュレーション 第1回】",
    "title_match": [
      "配膳ロボット",
      "何台"
    ],
    "seconds": 860,
    "url": "",
    "youtube_id": ""
  },
  "posts": {
    "x_template": "配膳ロボット、増やすほど店は良くなる？\n\nファミレスの混む2時間をパソコンの中で試算したら、席に着くまでが縮んだのは2台目まで。1台だと料理はかえって遅くなった…\n台数は、誰が先に運ぶかの決まりとセットで決めたいね。解説動画にしたよ。\n#配膳ロボット #飲食店\n{url}"
  },
  "note": "YouTube の本編の X 投稿。url と posts.x は tools/sync_youtube_shorts.py が YouTube の公開予約を見て入れる。LinkedIn は動画を直接アップロードする決まりなので入れない（文は iCloud の 動画/何台入れたら店は変わる/投稿文_X_LinkedIn.txt）"
}
```

### r2r2r-content の commit c139161 の差分（同期の部品と docs/sns_autopost.md）

````diff
commit c13916168e50c83107f094cbd2ba6243c62b846c
Author: Claude <noreply@anthropic.com>
Date:   Sat Oct 24 23:57:52 2026 +0900

    SNS予約：YouTube の本編の X 投稿も、公開予約に合わせて予定表に入れる
    
    - tools/sync_youtube_shorts.py が sns/VIDEO_*_sns_posts.json も読む（タイトル、なければ語と長さで結びつけ、URL は youtu.be）
    - 1本目：何台入れたら店は変わる（配膳ロボット導入シミュレーション 第1回）
    - 試し：いまの youtube_videos.json では変わるものなし（前と同じ）。タイトルを直して上げた想定の偽の行では、19:30・20:00 の重なりを避けて 20:30 に入り、check も通った
    
    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01CF5RbAfS7xaH2fgqEUe7xG

diff --git a/docs/sns_autopost.md b/docs/sns_autopost.md
index dc9ef32..b7f70a9 100644
--- a/docs/sns_autopost.md
+++ b/docs/sns_autopost.md
@@ -64,9 +64,25 @@ Facebook は個人アカウントへの API 投稿ができないので、これ
 - 同じ時刻に X の予約があれば、19:30 なら 20:00、それ以外は30分ずつ後ろへずらす
 - YouTube で公開の時刻を変えたら、まだ投稿していない予約の時刻も次の同期で直る。公開の時刻を過ぎてから見つけたものは入れない
 - LinkedIn には入れない（動画は LinkedIn に直接アップロードする決まりのため、手で出す）
-- 同期は予定のタスク「ショートのX予約をYouTubeに合わせる」が1日2回（7:47・17:47）動かす。Mac が眠っていると、その回は何もしない
+- 同期は予定のタスク「ショートと本編のX予約をYouTubeに合わせる」が1日2回（7:47・17:47）動かす（本編も同じ回で入る）。Mac が眠っていると、その回は何もしない
 - YouTube へのアップロードと公開予約は、まだ手で行う（API で上げるには Google の審査が要る。審査前に API で上げた動画は非公開から変えられない）
 
+### YouTube の本編（sns/VIDEO_{名前}_sns_posts.json）
+
+本編の動画も、YouTube の公開予約に合わせて X に出す（2026-10-24 エイイチの指示。「何台入れたら店は変わる」から）。ショートと同じ同期（`tools/sync_youtube_shorts.py`）で入る。
+
+```json
+{"meta": {"kind": "youtube_video", "title": "動画名（作業フォルダの名前）",
+          "youtube_title": "タイトル.txt のタイトル", "title_match": ["配膳ロボット", "何台"], "seconds": 860,
+          "url": "（自動）", "youtube_id": "（自動）"},
+ "posts": {"x_template": "本文…\n{url}\n#R2R2R #PixelsToMotors", "x": "（自動）"}}
+```
+
+- 文は動画を作った会話で、投稿文_X_LinkedIn.txt の X の文から作って置く（`{url}` が `https://youtu.be/{id}` に替わる。URL込み140字）。ファイル名の `{名前}` は英数字とハイフン
+- 動画との結びつけ: YouTube のタイトルが `youtube_title` と同じもの。なければ、`title_match` の語をすべて含み、長さ（秒）が `seconds` と3秒以内のもの（上げるときにタイトルを直しても結びつく）
+- 時刻の決まり（19:30 が重なったら 20:00、ほかは30分ずつ後ろへ）・公開の時刻を過ぎてから見つけたものは入れない・時刻を変えたら次の同期で直る、はショートと同じ
+- LinkedIn には入れない（動画は LinkedIn に直接アップロードする決まり）
+
 ## 結果（sns/posted.json）
 
 | status | 意味 | どうするか |
diff --git a/tools/sync_youtube_shorts.py b/tools/sync_youtube_shorts.py
index 558c08c..b6fcf83 100644
--- a/tools/sync_youtube_shorts.py
+++ b/tools/sync_youtube_shorts.py
@@ -1,13 +1,17 @@
 #!/usr/bin/env python3
-"""sync_youtube_shorts.py — YouTube で公開予約したショートの X 投稿を、公開の時刻に合わせて予定表に入れる。
+"""sync_youtube_shorts.py — YouTube で公開予約したショートと本編の X 投稿を、公開の時刻に合わせて予定表に入れる。
 
 元になるもの:
   sns/SHORT_ep{N}_sns_posts.json   ショートの X の文（posts.x_template。{url} がショートの URL に替わる）
+  sns/VIDEO_{名前}_sns_posts.json  本編の X の文（2026-10-24 から。{url} が https://youtu.be/{id} に替わる）。
+                                   動画との結びつけは、タイトルが meta.youtube_title と同じもの、
+                                   なければ meta.title_match の語をすべて含み、長さが meta.seconds と3秒以内のもの
+                                   （YouTube に上げるときにタイトルを直しても結びつくように）
   youtube_videos.json              エイイチの Mac が1時間ごとに書き出す自分のチャンネルの一覧
                                    （_制作ベース/youtube_schedule.py。説明文の manga/ep{N} から manga_ep を拾う）
 
 やること:
-  1. manga_ep（なければタイトル）で、ショートの json と YouTube の動画を結びつける
+  1. ショートは manga_ep（なければタイトル）、本編はタイトル（なければ語と長さ）で、json と YouTube の動画を結びつける
   2. 公開予約（または公開済み）の時刻を X の予約の時刻にする。同じ時刻に別の X の予約があれば、
      19:30 なら 20:00、それ以外は30分ずつ後ろへずらす（2026-10-24 エイイチの指示「19:30が重なったら動画は20:00」）
   3. まだ投稿していない予約は、YouTube 側で時刻を変えていたら合わせて直す
@@ -41,29 +45,41 @@ def main():
     ap.add_argument("--videos", required=True)
     ap.add_argument("--dry-run", action="store_true")
     a = ap.parse_args()
-    vids = [v for v in load(a.videos, {}).get("動画", []) if v.get("is_short")]
+    allv = load(a.videos, {}).get("動画", [])
+    vids = [v for v in allv if v.get("is_short")]
+    mains = [v for v in allv if not v.get("is_short")]
     sched = load(SCHEDULE, {"posts": []})
     posted = load(POSTED, {})
     done_ids = set(posted.keys()) if isinstance(posted, dict) else {p.get("id") for p in posted.get("posts", posted) if isinstance(p, dict)}
     now = datetime.now(timezone.utc)
     changes = []
-    for path in sorted(glob.glob(os.path.join(ROOT, "sns", "SHORT_ep*_sns_posts.json"))):
+    paths = sorted(glob.glob(os.path.join(ROOT, "sns", "SHORT_ep*_sns_posts.json"))) + \
+        sorted(glob.glob(os.path.join(ROOT, "sns", "VIDEO_*_sns_posts.json")))
+    for path in paths:
         src = json.load(open(path, encoding="utf-8"))
         meta, posts = src["meta"], src["posts"]
-        ep = meta["episode"]
-        cand = [v for v in vids if v.get("manga_ep") == ep] or [v for v in vids if norm(v["title"]) == norm(meta.get("youtube_title"))]
+        is_main = os.path.basename(path).startswith("VIDEO_")
+        if is_main:
+            words = meta.get("title_match") or []
+            cand = [v for v in mains if norm(v["title"]) == norm(meta.get("youtube_title"))] or \
+                [v for v in mains if words and all(w in v["title"] for w in words)
+                 and meta.get("seconds") and v.get("seconds") and abs(v["seconds"] - meta["seconds"]) <= 3]
+            sid = os.path.basename(path)[:-len("_sns_posts.json")] + "-x"
+        else:
+            ep = meta["episode"]
+            cand = [v for v in vids if v.get("manga_ep") == ep] or [v for v in vids if norm(v["title"]) == norm(meta.get("youtube_title"))]
+            sid = f"SHORT_ep{ep}-x"
         cand = [v for v in cand if v.get("publishAt") or v.get("published")]
         if not cand:
             continue
         v = sorted(cand, key=lambda v: v.get("publishAt") or v.get("published"))[-1]
         when = datetime.fromisoformat((v.get("publishAt") or v.get("published")).replace("Z", "+00:00"))
-        sid = f"SHORT_ep{ep}-x"
         entry = next((e for e in sched["posts"] if e["id"] == sid), None)
         if entry and sid in done_ids:
             continue
         if not entry and when < now:
             continue
-        url = f"https://youtube.com/shorts/{v['id']}"
+        url = f"https://youtu.be/{v['id']}" if is_main else f"https://youtube.com/shorts/{v['id']}"
         text = posts["x_template"].replace("{url}", url)
         if posts.get("x") != text or meta.get("url") != url or meta.get("youtube_id") != v["id"]:
             posts["x"], meta["url"], meta["youtube_id"] = text, url, v["id"]
````
