---
title: "マンガのショートを20本作る ― 吹き出しを切らずに切り抜く"
slug: manga-shorts-speech-bubbles
date: 2026-10-24
day: 377
meta_description: "4コママンガ『ハコブくん』を約20秒の縦長動画にするショートを、ひと晩で20本作った。コマを四角で切ると枠からはみ出した吹き出しが切れ、重ねるとふちがガサガサになった。吹き出しを見つけて形のまま重ね、ふちを描き直した記録。"
tags: [YouTube, YouTubeショート, 動画制作, ハコブくん, 4コママンガ, 吹き出し, ElevenLabs, Python, SciPy, 予約投稿, Claude, AI活用]
series: "ひとり研究所の裏側"
cover: "/media/LOG377_cover.jpg"
ai_consult: true
---

マンガの吹き出しは、コマの枠の中に収まっているとは限りません。セリフが長いときや、勢いを出したいとき、吹き出しは枠を突き抜けて隣の余白まではみ出して描かれます。紙のマンガでは気にならないこの描き方が、マンガを縦長の動画に作り直すときには困りものになります。コマを四角く切り取ると、はみ出した部分が一緒に切れてしまうからです。

この記事は、4コママンガ『ハコブくん』を YouTube のショート動画（縦長で60秒までの短い動画）に作り直す作業の記録です。ハコブくんは、病院で薬や検体を運ぶロボットが、人の言葉をまっすぐ受け取りすぎて失敗するつくり話です。ショートは、最初の数秒でハコブくんが動き、そのあとマンガのコマを1枚ずつ見せ、看護師さんやハコブくんのセリフを声で読み上げる、約20秒の動画にしています（作り方の型は LOG354 で決めました）。

ひと晩で20本を作り、見せたところで最初に返ってきたのが、この一言でした。

> 吹き出しってもっと綺麗に切り取れないの？

続けて「見切れているけど？」。調べると、**吹き出しが切れていた原因は、コマを決まった四角の範囲で切り取っていたことでした。** 枠からはみ出した吹き出しは、四角の外に出た分だけ消えていました。そこで、絵の中から吹き出しを自動で見つけ、切り取る範囲を吹き出しに合わせて動かし、それでも枠の外にはみ出すものは形のまま上に重ねるようにしました。すると今度は「切り抜きが汚い、ガサガサしている」。重ねた吹き出しのふちに、まわりの絵の切れ端が付いていたためです。吹き出しの中だけを取り出し、ふちの線を描き直して直しました。

【画像】LOG377_01_twenty_shorts.png 作った20本のショートの冒頭のコマを並べたもの。どれも上に見出し2行、真ん中に病院の背景と動くハコブくん、下に字幕がある

| やったこと | なぜ | 結果 |
|---|---|---|
| まだショートにしていないマンガから20話を選んだ | ショートだけのために作る動画を増やすため | 第7〜98話から、意外な一言で終わる20話。長さは16〜30秒、20本で約7分50秒 |
| 声と背景を用意した | 話ごとに場所と登場人物が違うため | 107文の声を作った。声を作るサービスの上限（自作の声10個）に当たり、2人は別の人の声を借りた。背景は8枚を新しく作り、12話は前の絵を使い回した |
| 冒頭のハコブくんの動きを20個足した | 20本の頭が同じ動きに見えないようにするため | 忍者のように気配を消す、イケメンの声で話す、外装を外すと光る目が1つ、など話ごとに違う動き |
| 吹き出しを切らずにコマを切り取る仕組みを作った | 枠からはみ出した吹き出しが切れていたため | 59コマのうち34コマで、枠の外にはみ出した吹き出しを形のまま重ねた |
| 重ねた吹き出しのふちを描き直した | ふちがガサガサで、枠の線が2重になっていたため | ふちが一本のなめらかな線になり、枠も1本にそろった |
| X への投稿を、YouTube の公開予約に合わせて自動で予約するようにした | 動画を上げるたびに投稿を手で予約するのは続かないため | YouTube で予約した日時に、X の投稿が自動で入る。31本ぶんの投稿文を用意した |

どのショートも、まだ YouTube には出ていません（10月26日から平日に1本ずつ公開する予約にしています）。作り直す前のショートは、見た人の81.0%がスワイプして次の動画へ流していました（LOG354）。吹き出しがきちんと読めることが、その割合を下げる助けになるかは、公開後に同じ分析画面で確かめます。

ここから下は作業の記録です。指摘を受けて直した所と、使ったコード・設定の全文を残します。


## 作業ログ ― ショート専用のフォルダの20本のうち、10本が YouTube に出ていなかった

最初の質問は「ショート専用で YouTube に投稿されていない動画はどれか」でした。ショート専用の動画（本編がなく、ショートだけの企画）は、KIOXIA（外付けの SSD）の `r2r2r-data/videos/_ショート専用/` に1話1フォルダで置いています。Mac が1時間ごとに書き出している自分のチャンネルの一覧（`_制作ベース/youtube_videos.json`）と、フォルダの中の mp4 を突き合わせました。

```
cd $HOME/mnt/KIOXIA/r2r2r-data/videos/_ショート専用 && for d in */; do printf "%s: " "$d"; ls "$d" | grep -E '^ショート_.*\.mp4$' | tr '\n' ' '; echo; done
```

結果は次のとおりです（照らし合わせたのは 18:05 時点の一覧）。

| 状態 | 話 |
|---|---|
| まだ出していない（10本） | 第22話_消毒、第24話_シュレッダー、第29話_ハワイ、第43話_謝罪、第81話_かぼちゃ、第82話_ため息、第83話_確認、第85話_取り消し、第87話_小声、第88話_ついてきて |
| 予約済み（1本） | 第41話_公平（10/25 8:30） |
| 公開済み（9本） | 第18・19・20・21・48・67・79・80・86話 |

10本は、その場で YouTube に上げてもらいました。そのあとの依頼がこれです。

> 新しいショート専用の動画を20本作成してください。動画作成のルールをよく読んでください。背景画像はchatGPTに依頼するので指示を出してください（前に作ったの使い回しでも可。新しいのが必要なら作ります）

この記事の「見込める効果」は、ことわりのない限り、まだ測っていない見込みです。数字で書いてあるもの（長さ・コマの数・予約の件数）は、書き出した動画と設定のファイルから数えた値です。


## 20話を選び、声を4人ぶん頼み、背景を8枚頼んだ

動画の作り方の決まり（`_制作ベース/video_rules.md`）の0節と9節、前回の10本の台本、サイトのマンガの一覧（pixelstomotors.com/manga）を読んでから、まだショートにしていない回の中から「え？」となる一言で終わる20話を選びました。

| 話 | 題材 |
|---|---|
| 第7話 ピンポン | 深刻な告知の最中に、検便容器の回収をアナウンスする |
| 第9話 外装 | 外装を外した中身に、光る目が1つギョロッ |
| 第17話 イケボ | アップデートで「御意。姫君」と話し出す |
| 第23話 忍者 | 静音モードで、気配ゼロで背後にいる |
| 第46話 メニュー | 音量を下げたいだけなのに、設定がどこまでも深い |
| 第47話 8時間 | 8時間前から警告していたのに、誰も気づかない |
| 第51話 自己診断 | 目が悪くなったのに、自己診断は「正常」 |
| 第52話 夜勤 | 気をきかせて「今月の夜勤、40%多いです」 |
| 第60話 再現しない | 見張っている間は、バグが起きない |
| 第62話 確信度 | 「了解しました」と即答。本当は自信62% |
| 第63話 通行止め | 昨日できた壁の前で「経路、再計算」をくり返す |
| 第65話 道案内 | 道を聞いただけで「職員認証が必要です」 |
| 第71話 置いてけぼり | 案内したら、振り返ると誰もいない |
| 第72話 トイレ | 「トイレどこ？」にも「経路を継続します」 |
| 第84話 プリン | 先生の声まねで頼まれたプリンを、本物の先生に届ける |
| 第89話 お手すき | 「お手すきの時で…」を言葉どおり後回しにする |
| 第90話 伝言 | 遠慮がちな伝言を要約して「誤りがあります」と断定 |
| 第91話 心拍数 | 笑顔で隠した緊張を「心拍数1.8倍」と発表する |
| 第92話 なかったことに | 「なかったことに」を「記録は消せません」と断る |
| 第98話 起こさない | 「絶対に起こさないで」を一晩守りきる |

### 新しい声を4つ作る手順

20話には、これまでのショートに出ていない人が4人います。入院患者の中村拓也さん、面会に来た森田茂さんと森田久美子さん、そして第17話のアップデートで声が変わったハコブくん（イケメンの声）です。声は ElevenLabs（声を作る AI のサービス）の「声をデザインする」機能で作ります。API の鍵を使うので、Mac のターミナルで動かしてもらいました。`voice_design.py` に4人の説明を足してあります（ファイルの全文は最後にあります）。

```
cd ~/Library/Mobile\ Documents/com~apple~CloudDocs/デスクトップ/ADAPT\ DESIGN/じぶんの/PixelstoMotors/デジタルツイン学習R2R2R/動画/_制作ベース
read -s ELEVENLABS_API_KEY && export ELEVENLABS_API_KEY
python3 voice_design.py design 中村
python3 voice_design.py design 森田茂
python3 voice_design.py design 森田久美子
python3 voice_design.py design ハコブ_イケボ
```

`声の候補/{名前}/候補1〜3.mp3` を聞いて選んでもらった番号が、これです。

> ハコブ_イケボ1
> 森田久美子2
> 森田茂1
> 中村1

20話の107文は `_ショート専用/20話のセリフ.json` に書き、`20話の声.sh` でまとめて声にします。

```
python3 voice_design.py save ハコブ_イケボ 1
python3 voice_design.py save 森田久美子 2
python3 voice_design.py save 森田茂 1
python3 voice_design.py save 中村 1
cd ../_ショート専用 && bash 20話の声.sh
```

### 背景は8枚を新しく、12話は使い回した

背景は ChatGPT に頼みます。ロボットのいない病室の絵（`bg_empty.png`）を添付し、カメラの高さと床の位置をそろえたまま、場所だけを変えてもらう決まりです（`_ショート専用/背景/背景のプロンプト.md`）。新しく頼んだのは、前に作った絵では場所が合わない8話です。

| 新しく作った背景 | 使い回した背景 |
|---|---|
| 第7話 面談室、第9話 機材室、第52話 休憩室、第63話 工事中のパーテーション、第72話 1階の受付そばの廊下、第90話 医局、第91話 採血室、第98話 夜明け前の当直室の前 | 第17話←第24話、第23話←第67話、第46話←第82話、第47話←第48話、第51話←第41話、第60話←第83話、第62話←第19話、第65話←第22話、第71話←第86話、第84話←bg_empty、第89話←第85話、第92話←第20話 |

マンガの登場人物の名前が出てこない回は、見た目から読む人を決めました。第17話の眼鏡の人は寺島さん、お団子の髪の人は大城さん。名前の出ないお団子の看護師さん（第62・65・72・89・91話）は大城さんの声、第63話のボブの人は高杉さんの声で読みます。第84話の声まね「プリンを持ってきてくれ」は、田中先生本人の声で読ませました。ハコブくんが声だけでは聞き分けられなかった、という話が伝わるようにするためです。


## ElevenLabs の自作の声が上限の10個で、2人は別の人の声を借りた

声を保存するところで止まりました。ターミナルに出た表示です。

```
(base) shimazueiichi@shimadzueiichinoMacBook-Pro _ショート専用 % cd ../_制作ベース
python3 voice_design.py save 森田茂 1
python3 voice_design.py save 中村 1
止まりました: 400 {"detail":{"type":"invalid_request","code":"bad_request","message":"You have reached your maximum amount of custom voices (10 / 10). You can upgrade your subscription to increase your custom voice limit.","status":"voice_limit_reached","request_id":"7234d8d2f45043ad22b6322c4ecaf275"}}
止まりました: 400 {"detail":{"type":"invalid_request","code":"bad_request","message":"You have reached your maximum amount of custom voices (10 / 10). You can upgrade your subscription to increase your custom voice limit.","status":"voice_limit_reached","request_id":"6f872af8955faf9b56b1325d45ee6d9b"}}
(base) shimazueiichi@shimadzueiichinoMacBook-Pro _制作ベース % cd ../_制作ベース 
python3 voice_design.py save 森田茂 1
python3 voice_design.py save 中村 1
cd ../_ショート専用 && bash 20話の声.sh
止まりました: 400 {"detail":{"type":"invalid_request","code":"bad_request","message":"You have reached your maximum amount of custom voices (10 / 10). You can upgrade your subscription to increase your custom voice limit.","status":"voice_limit_reached","request_id":"093f9143dadfc922c3d5576010854633"}}
止まりました: 400 {"detail":{"type":"invalid_request","code":"bad_request","message":"You have reached your maximum amount of custom voices (10 / 10). You can upgrade your subscription to increase your custom voice limit.","status":"voice_limit_reached","request_id":"e2f5393a23557d0c34a8d4b221ea2415"}}
== 第7話_ピンポン
== 第9話_外装
== 第17話_イケボ
== 第23話_忍者
== 第46話_メニュー
== 第47話_8時間
== 第51話_自己診断
== 第52話_夜勤
== 第60話_再現しない
== 第62話_確信度
== 第63話_通行止め
== 第65話_道案内
  ！ 森田茂 の声がまだ保存されていないので、とばしました
== 第71話_置いてけぼり
== 第72話_トイレ
  ！ 中村 の声がまだ保存されていないので、とばしました
== 第84話_プリン
  ！ 中村 の声がまだ保存されていないので、とばしました
== 第89話_お手すき
== 第90話_伝言
== 第91話_心拍数
  ！ 中村 の声がまだ保存されていないので、とばしました
== 第92話_なかったことに
== 第98話_起こさない
とばした声があります。声を保存してから、もう一度 bash 20話の声.sh
(base) shimazueiichi@shimadzueiichinoMacBook-Pro _ショート専用 %
```

いまのプランでは、自分で作った声は10個までです。ハコブ_イケボと森田久美子さんで10個に達し、森田茂さんと中村さんが入りませんでした。選べる道は2つです。

| 道 | 中身 |
|---|---|
| 今ある声を借りる | 中村さん＝槙原さん（エンジニア）の声、森田茂さん＝田中先生（医師）の声。借りた声の本人が同じ話に出てこない組み合わせにする。追加の費用なし |
| 枠を空ける | ElevenLabs の画面で使っていない声を2つ消して、保存をやり直す |

> 追加する？

声の枠を増やすにはプランを変える必要があるため、借りる方にしました。`20話の声.sh` の、人の名前から声を選ぶ所に2行を足しています（中村→槙原、森田茂→田中）。足りなかった5文だけを作り直してもらい、20話ぶんの声がそろいました。

声のファイルは Mac の iCloud にできます。作業はクラウドの作業環境（メモリが多い）で行うため、tgz にまとめて KIOXIA 経由で取り込み、md5（ファイルの中身から作る短い値）が同じことを確かめてから使いました。


## 台本ファイル（spec）を、声の長さから時刻が決まる形にした

1本のショートは、話ごとの台本ファイル `spec_epNN.py` から組みます。見出し、冒頭の動き、コマの切り抜き、セリフと字幕、まとめの画面を書き、`マンガショート_mkshort.py` に渡すと映像と音が1本になります。前回までと変えたのは3つです。

1つ目は、冒頭のハコブくんの動きの時刻を、声の長さから台本の中で決めるようにしたことです。たとえば第98話では、小芝先生の「朝まで、絶対に起こさないでね」が終わってから0.2秒後にハコブくんが返事をし、その返事が終わってから見張りの動きに入ります。

```python
l1 = ns("小芝", 1, "朝まで、|【絶対に】起こさないでね。", at=0.9)
t2 = 0.9 + d(l1, 2.0) + 0.2
l2 = hk(1, "了解しました。", at=t2)
S = t2 + d(l2, 1.0) + 0.2
M = S + 2.4
DUR = M + 1.1
MOTION = mot("見張り", EP, S=S, M=M, dur=DUR)
```

`d()` は声のファイルの、前後の無音を落とした長さです（`common24.py` の `vlen`）。声がまだないときは仮の長さ（引数の2つ目）で組めるので、声を待たずに冒頭の動きと台本を進められました。

2つ目は、字幕を `||` でページに分けられるようにしたことです。字幕は1ページ2行まで、1行14字までです。3行にすると、画面の下のふち（y1640）より下にはみ出していました。長いセリフは、声の長さを字数で割り振って、ページを順に出します。字数は `checksubs.py` で確かめます。

```
$ python3 checksubs.py
要確認: 0
```

3つ目は、横に長い4コマ目の扱いです。4コマ目が横に長い回は、幅1040pxに縮めて入れると、小さな帯になってしまいます。話している人ごとに、500〜650pxの幅で2つに切り分け、2つの場面にしました。

- ねらい：4コマ目のセリフと表情を、スマホの縦長の画面でも読める大きさで見せる
- 変わったこと：20話のうち13話で、横に長いコマを話す人ごとに2つの場面に分けた。コマは20話で59コマになった
- 見込める効果：オチのコマで字が小さくて読めない、ということが減る。最後まで見てもらえたかは、公開後の「視聴を継続」の割合で確かめる

作業の途中では、各場面の頭の静止画を書き出して1枚に並べ（`sheets.py`）、目で見て切り抜きと字幕を確かめました。


## 冒頭のハコブくんの動きを20個足した

冒頭の数秒は、コードで動かすハコブくんです（`hakobu_motion.py`。体の画像を揺らす・跳ねさせる・傾ける、画面の顔とリングの光を描き替える）。前回、似た動きが並ぶと「みんな同じ動き」に見えると指摘されたので、20話それぞれに別の動きを足しました。

| 動きの名前 | 話 | 中身 |
|---|---|---|
| アナウンス | 第7話 | 胸を張って大声のアナウンス |
| ギョロ | 第9話 | 外装を外した黒い体に、光る目が1つ |
| イケボ | 第17話 | 流し目のイケメン顔で話す |
| 忍者 | 第23話 | スーッと音もなく近づき、背後で止まる |
| メニュー | 第46話 | 画面に設定の階層がどんどん深く出る |
| カタッ | 第47話 | 右の車輪がカタッと沈む |
| 自己診断 | 第51話 | 画面に「全機能、正常」と出る |
| 夜勤スロット | 第52話 | 画面に「今月ノ夜勤 +33%」 |
| ピタッ | 第60話 | 見られた瞬間にピタッと止まる |
| 確信度 | 第62話 | 即答の笑顔の下に確信度のゲージ |
| 経路再計算 | 第63話 | 壁の前でウロウロ |
| 職員認証 | 第65話 | 画面に認証の要求 |
| 置いてけぼり | 第71話 | 速いまま案内して進む |
| 案内 | 第72話 | 画面に「目的地 405号室」 |
| プリン | 第84話 | プリンを運んで来る |
| 優先度低 | 第89話 | 依頼を受けて「低」の箱に入れる |
| 伝言 | 第90話 | 画面に「伝言 要約中」 |
| 心拍 | 第91話 | 画面に心拍の波形 |
| 消せない | 第92話 | ヒソヒソ声の依頼に首を振る |
| 見張り | 第98話 | 夜の当直室の前で朝まで待機 |

最初の1秒が止まった絵にならないよう、動きの頭は「右から来て止まる」か、最初から動いているものにしました。体を左右に反転させている間は、画面の字が鏡文字になるので、字を出しません。この2つは video_rules.md の決まりに足しました。

20本の冒頭は、クラウドの作業環境で2本ずつ並べて書き出しました（`runall.sh`）。Mac の作業用の環境はメモリが3GBで、重い書き出しは途中で止まったため、書き出しはすべてクラウド側で行い、できた mp4 を Mac に戻しています。


## 第52話の背景が、横長の白黒で届いた

> 背景画像がダウンロードフォルダに入っています

ダウンロードのフォルダから8枚を取り込み、並べて見ました。7枚はそのまま使えましたが、第52話（休憩室）だけは 2048×768 の横長の白黒でした。ショートの背景は縦長 4:5（1122×1402前後）のカラーです。同じ決まりで作り直してもらい、届いた絵の大きさを確かめてから使いました。

```
python3 -c "from PIL import Image; print(Image.open('$HOME/mnt/Downloads/第52話.png').size)"
```

このあと、届いた背景は大きさを確かめてから使う、という1行を video_rules.md に足しています。


## 30秒を超えた3本から、セリフを抜いた

ショートは30秒以内を目安にしています。書き出すと、第60・89・90話の3本が30秒を超えました。話の筋に要らない文と場面を抜きました。

| 話 | 抜いたもの |
|---|---|
| 第60話 再現しない | 大城さんの「週に2、3回」の場面 |
| 第89話 お手すき | 小芝先生の「言っちゃいました…」 |
| 第90話 伝言 | 「ガーン」の場面と、小芝先生の「は、はい…」 |

できあがった20本の長さです（`ffprobe` で測った値）。

| 話 | 長さ（秒） | 話 | 長さ（秒） |
|---|---|---|---|
| 第7話 ピンポン | 16.47 | 第63話 通行止め | 22.00 |
| 第9話 外装 | 18.87 | 第65話 道案内 | 20.30 |
| 第17話 イケボ | 25.50 | 第71話 置いてけぼり | 20.23 |
| 第23話 忍者 | 19.07 | 第72話 トイレ | 22.73 |
| 第46話 メニュー | 22.47 | 第84話 プリン | 24.93 |
| 第47話 8時間 | 19.77 | 第89話 お手すき | 28.00 |
| 第51話 自己診断 | 23.17 | 第90話 伝言 | 30.10 |
| 第52話 夜勤 | 29.40 | 第91話 心拍数 | 25.03 |
| 第60話 再現しない | 25.30 | 第92話 なかったことに | 19.97 |
| 第62話 確信度 | 27.53 | 第98話 起こさない | 27.80 |

合計は468.6秒（約7分49秒）、1本あたり平均23.4秒です。第90話は30.1秒で、0.1秒だけ超えたまま残っています。

各話のフォルダには、mp4 と一緒に YouTube に貼るタイトルと説明文（`ショート_タイトルと説明文.txt`）を置きました。説明文には、マンガのその話のページの URL を必ず入れています。この URL があとで、YouTube の動画と X の投稿を結びつける手がかりになります。


## コマを四角で切ると、枠からはみ出した吹き出しが切れていた

20本を KIOXIA に置き、見本に第17話を見てもらったあとの指摘です。

> 吹き出しってもっと綺麗に切り取れないの？

> 見切れているけど？

これまでの切り抜きは、台本に書いた四角の範囲 `crop=(左, 上, 右, 下)` をそのまま切るだけでした。マンガの吹き出しは、コマの枠を突き抜けて描かれていることが多く、四角の外に出た部分は消えます。下の画像の左が、そのころの第98話です。「そういう時は起こしてよ！」の吹き出しの上半分がありません。

【画像】LOG377_02_bubble_three_versions.png 第98話の同じ場面を3つ並べたもの。左は四角で切ったため吹き出しの上が切れている。真ん中は吹き出しを重ねたが、ふちに絵の切れ端が付き、枠の線が2本ある。右はふちを描き直し、枠を1本にした最終版

### 吹き出しを「白い所の中に、字の粒が並ぶもの」で見つける

切り抜きの範囲を吹き出しに合わせて動かすには、まず絵の中から吹き出しを見つける必要があります。マンガの絵は白黒なので、白い所（明るさ200より上）のつながりを1つずつ調べ、次の条件をすべて満たすものを吹き出しとみなしました（`bubblefit.py` の `bubbles`）。

| 条件 | 理由 |
|---|---|
| 面積が2,500px以上、縦横とも40px以上 | 小さな白い点を拾わない |
| 穴を埋めた形が、外接する四角の35%以上を占める | 細長い白（服のすき間など）を拾わない |
| 中に黒い粒が4つ以上あり、黒の割合が30%以下 | 字が入っている。黒すぎる所（髪・服の模様）は除く |
| 小さな粒（70px未満）が6つ以上、大きな粒（70px以上）がない | 字は小さな粒が並ぶ。聴診器や服のしわのような長い線があれば吹き出しではない |
| 粒の高さの中央が6px以上 | 点や網かけだけの所を除く |
| 中心がそろった粒（12px以内）が、行か列に4つ以上 | 字は行（横書き）か列（縦書き）にそろって並ぶ |
| 幅760px・高さ520px以下（幅480pxかつ高さ300pxを超えるものも除く） | コマ全体の白い背景を拾わない |

最後の条件は、試すうちに足したものです。白い背景の大きなコマに字が並んでいると、コマ全体を1つの吹き出しとして拾ってしまいました。もう1つ、ページの外に開いている吹き出し（ページの端の余白につながっている吹き出し）は、白い所がページの余白とつながって、1つの大きな白になります。ページの端に近い枠の線を黒で閉じてから探すようにしました（`_closed`）。

### 切り抜きの範囲を、吹き出しに合わせて動かす

見つけた吹き出しが切り抜きの辺にかかっていたら、範囲を動かします（`fit`）。

1. 切り抜きの辺の近く（±45px）で、黒が4割を超える行・列をコマの枠の線とみなし、その線の外までを動かせる範囲にする
2. 吹き出しの35%以上が切り抜きの中にあれば、範囲を広げて全部入れる
3. 少しだけかかっているなら、その辺を狭めて外す。ただし、狭める量が切り抜きの幅（または高さ）の15%を超えるなら、狭めずに広げて入れる
4. 動かすと別の吹き出しがかかることがあるので、変わらなくなるまで最大3回くり返す

コマの枠の外へは広げません。枠の外に広げると、隣のコマの絵が入るからです。

### それでも枠の外にはみ出す吹き出しは、形のまま重ねる

枠を突き抜けて描かれた吹き出しは、範囲を枠いっぱいまで広げても入りきりません。そうした吹き出しは、切り抜いたコマの上に、吹き出しの形のまま重ねて出します（`framed`）。吹き出しの面積の19%以上が切り抜きの中にあるものだけを重ねます。数字を小さくすると、隣のコマの人の吹き出しの端まで重なって出てきたためです。

自動で外れなかった隣の吹き出しが3か所（第47・63・89話）ありました。台本の場面に、その吹き出しの中の1点を `skip=[(x, y)]` で書いて外しています。拾えない吹き出しは `bub=[(x, y)]` で足し、画面を拡大して見せる場面のように、今までどおり四角で切りたいときは `fit=False` と書きます（第46話の1か所）。

```python
dict(kind="panel", crop=(21, 943, 455, 1332), skip=[(590, 1020)], ...)   # 第47話
```

20話の59コマを、直したあとの切り抜きで数え直した結果です。

```
(7, 2, 2, 0)
(9, 3, 3, 0)
(17, 3, 3, 2)
(23, 4, 4, 0)
(46, 3, 2, 0)
(47, 3, 3, 1)
(51, 3, 3, 2)
(52, 4, 4, 2)
(60, 4, 4, 0)
(62, 3, 3, 3)
(63, 3, 3, 1)
(65, 3, 3, 3)
(71, 3, 3, 3)
(72, 2, 2, 2)
(84, 3, 3, 3)
(89, 3, 3, 3)
(90, 3, 3, 2)
(91, 3, 3, 3)
(92, 2, 2, 2)
(98, 2, 2, 2)
panels 59 crop moved 58 with overlay 34 fit=False 1 skip 3
```

左から、話・コマの数・範囲を動かしたコマ（枠の線へのそろえを含む）・吹き出しを重ねたコマです。**59コマのうち34コマに、枠の外へはみ出した吹き出しがありました。** 半分を超えるコマで、四角で切ると何かが欠けていたことになります。

- ねらい：マンガのセリフを、欠けずに全文読めるようにする
- 変わったこと：吹き出しを自動で見つけて、切り抜きの範囲を合わせ、入りきらないものは形のまま重ねる。59コマのうち34コマで重ねた。台本で手を入れたのは4か所（skip 3・fit=False 1）
- 見込める効果：セリフが欠けて話のオチが分からない、ということがなくなる。次からの話も、台本に四角を書くだけで同じように切れるので、1本ごとの手直しが減る

切り方を変えたので、20本とも書き出し直し、KIOXIA の mp4 を入れ替えました。

【図解】LOG377_bubble_crop.html


## 重ねた吹き出しのふちが、ガサガサで枠が2重になっていた

> ちょっと切り抜きが汚いね
> ガサガサしているの見直して修正

上の画像の真ん中が、このときの版です。吹き出しは全文出ていますが、ふちのまわりに上のコマの絵の切れ端が付き、枠の線が2本並んでいます。拡大すると、こうなっていました。

【画像】LOG377_03_bubble_edge_zoom.png 第98話の吹き出しを拡大したもの。左は直す前で、ふちの外に絵の切れ端と2本の枠の線が見える。右は直したあとで、ふちが一本のなめらかな線になり、枠の線も1本

原因は2つでした。

| 見え方 | 原因 | 直し方 |
|---|---|---|
| ふちのガサガサ | 重ねる吹き出しを、ふちの線まで入れるために形を5〜7px太らせ、まわりの絵ごと切り取っていた | 吹き出しの中（白と字）だけを取り出し、ふちの線は描き直す |
| 枠の線が2重 | 枠の線を探すとき、隣のコマの枠の線まで拾っていた | 線が2本見つかったら、このコマの内側の1本にそろえ、上に描く枠と重ねて1本にする |

ふちの描き直しは `_clean_bubble` です。吹き出しの形を4倍の細かさに広げ、ぼかしてから0.5で切って形をなめらかにします。形の外側1.4px・内側1.3pxを線にし、元の大きさに縮めます。4倍で作ってから縮めるので、線のふちが段々（ギザギザ）になりません。線の太さは、元の絵の吹き出しの線（約2.7px）に合わせました。最初は外2.2px・内1.6pxで作り、太すぎたので細くしています。ぼかしの強さも、1.6から2.4、2.6と上げて、ふちの小さなでこぼこが残らない所に決めました。

```python
big = np.kron(m, np.ones((k, k), np.float32))
inner = gaussian_filter(big, 2.6 * k) > 0.5                  # 中の形をなめらかに
d_out = distance_transform_edt(~inner) / k                   # 元の大きさでの、外側への距離
d_in = distance_transform_edt(inner) / k
ink_hi = ((~inner) & (d_out <= out_w)) | (inner & (d_in <= in_w))   # 線: ふちの外 out_w・内 in_w
```

枠の線のそろえは `_panel` と `fit` です。切り抜きの辺の近くで黒が4割を超える行・列が2本見つかったら、内側の1本を使います。見つかった辺は、線の外側（左と上は3px外、右と下は4px外）までを切り抜きに入れ、その上に7pxの枠を描くので、線が1本に重なります。

- ねらい：重ねた吹き出しを、元のマンガの吹き出しと同じ見た目にする
- 変わったこと：ふちの切れ端と2重の枠が消えた。20本とも書き出し直し、書き出した動画から全部のコマを取り出して並べ、ギザギザ・2重の枠・余計な吹き出しがないことを目で確かめた
- 見込める効果：動画の中で、手で貼り付けたような違和感がなくなる。マンガのコマとしてそのまま読める

この版を見てもらい、返ってきたのは「OKです！」でした。


## ショートの X の投稿を、YouTube の公開予約に合わせて自動で予約した

> 予約投稿自動化を進めています。連携して投稿予約設定できますか？

X と LinkedIn の予約投稿の仕組みは、別の作業で組んだばかりでした（LOG376。GitHub の r2r2r-content の `sns/schedule.json` に投稿の日時を書くと、GitHub Actions が15分ごとに見て、時刻が来た投稿を出す）。ここに、ショートの X 投稿をつなぎます。

困るのは、YouTube へのアップロードと公開予約が手作業であることです。プログラムから動画を上げるには Google の審査が必要で、審査の前に上げた動画は非公開から変えられません。そこで、手で入れた YouTube の公開予約を読み取り、その時刻で X の投稿を予約する形にしました。

| 段 | 何が | 何をする |
|---|---|---|
| 1 | 人（YouTube Studio） | 動画を上げ、表の日時で公開予約する。説明文はそのまま貼る |
| 2 | Mac（1時間ごと） | `youtube_schedule.py` が自分のチャンネルの一覧を `youtube_videos.json` に書き出す。説明文の `manga/ep{N}` から話の番号（`manga_ep`）を拾う |
| 3 | 予定のタスク（毎日 7:47・17:47） | 一覧を取り込み、`tools/sync_youtube_shorts.py` で話の番号から投稿文の json と動画を結びつけ、公開の時刻で `schedule.json` に入れて push する |
| 4 | GitHub Actions（15分ごと） | 時刻が来た投稿を X に出す |

`youtube_schedule.py` に足したのは、説明文から話の番号を拾う1行です。

```python
                "manga_ep": (lambda m: int(m.group(1)) if m else None)(re.search(r"/manga/ep(\d+)", sn.get("description", ""))),
                                                              # 説明文にあるマンガの話の番号（ショート専用のマンガのショート。2026-10-24・SNS の予約に使う）
```

X に出す時刻は、YouTube の公開と同じ時刻です。記事の投稿と同じ19:30に重なった日は、ショートを20:00にずらします（指示は「19:30が重なったら動画は20:00」）。YouTube 側で時刻を変えたときは、まだ投稿していない予約も次の回で合わせて直ります。公開の時刻を過ぎてから見つけたものは入れません。LinkedIn には入れていません。動画は LinkedIn に直接アップロードする決まりだからです。

投稿文は31本ぶん作り、`sns/SHORT_ep{N}_sns_posts.json` に置きました。すでに YouTube で予約・公開してあった11本と、新しい20本です。`{url}` の所が、ショートの URL に替わります。

```json
{
  "meta": {
    "kind": "youtube_short",
    "episode": 98,
    "title": "ショート 第98話 起こさない",
    "youtube_title": "「朝まで絶対に起こさないで」を、本当に一晩守りきったロボット #フィジカルai #ロボット",
    "manga_url": "https://pixelstomotors.com/manga/ep98",
    "url": "",
    "youtube_id": ""
  },
  "posts": {
    "x_template": "「朝まで絶対に起こさないで」を、一晩守りきったロボット。朝、承認待ちが3件…\n\n「絶対に」にも、破っていい例外がある。先に決めておきたいね。4コマ『ハコブくん』のショート\n\n{url}\n#ロボット #病院"
  },
  "note": "YouTube のショートの X 投稿。url と posts.x は tools/sync_youtube_shorts.py が YouTube の公開予約を見て入れる"
}
```

すでに YouTube で予約してあった11本は、その場で予定表に入れました。

| id | X に出す日時 |
|---|---|
| SHORT_ep41-x | 2026-10-25 08:30 |
| SHORT_ep22-x | 2026-10-26 20:00 |
| SHORT_ep24-x | 2026-10-27 20:00 |
| SHORT_ep29-x | 2026-10-28 20:00 |
| SHORT_ep43-x | 2026-10-29 20:00 |
| SHORT_ep81-x | 2026-10-30 20:00 |
| SHORT_ep82-x | 2026-11-02 20:00 |
| SHORT_ep83-x | 2026-11-03 19:30 |
| SHORT_ep85-x | 2026-11-04 20:00 |
| SHORT_ep87-x | 2026-11-05 20:00 |
| SHORT_ep88-x | 2026-11-06 20:00 |

新しい20本の YouTube の公開予約は、10/26（月）から11/24（火）までの平日12:30、1日1本にしました。祝日の11/3と11/23は空けています。表は KIOXIA の `_ショート専用/公開予約の表_20261024.txt` に置き、各話の説明文の先頭にも日時を書きました（全文は最後にあります）。YouTube で予約されたものから順に、X の予定表に入ります。

予定表の確かめと、記事を書いている時点での同期の結果です。

```
$ python3 tools/sns_autopost.py check
問題ありません。
$ python3 tools/sync_youtube_shorts.py --videos youtube_videos.json --dry-run
変わったものはありません
```

- ねらい：YouTube にショートを上げたら、X の投稿は何もしなくても同じ時刻に出るようにする
- 変わったこと：手でやるのは YouTube Studio での公開予約だけになった。X の31本ぶんの文が置いてあり、予約は1日2回の同期で入る
- 見込める効果：上げたのに X に出し忘れる、時刻がずれる、ということがなくなる。20本の公開が続く1か月、投稿の手間がかからない。実際に出たかは `sns/posted.json` で確かめる


## video_rules.md に足したこと（85〜87回目）

動画の作り方の決まり（`_制作ベース/video_rules.md`）の 9-0c に、今回の20話で決めたことを足しました。足した行の全文です。

```
- **2026-10-24 の20話（第7・9・17・23・46・47・51・52・60・62・63・65・71・72・84・89・90・91・92・98話）で足したこと**
  - 台本は `_制作ベース/マンガショート_台本_20261024/`（spec_epNN.py と common24.py・build.py）。冒頭の動きの時刻を、声の長さから spec の中で決める（`MOTION = mot(パターン, 話, A=…)`）。`build.py anim` で動き、`build.py still` で各場面の静止画、`build.py full` で書き出し。声がまだない間は、仮の声（字数×0.14秒）で組んで確かめられる
  - 字幕は `||` でページに分けられる（mkshort の add_sub。声の長さを字数で割り振る）。字幕は1ページ2行まで（3行だと y1640 より下にはみ出す）。1行14字まで・見出しは1行11字まで（checksubs.py で確かめる）
  - 横に長い4コマ目は、幅1040で入れると小さい帯になる。話す人ごとに 500〜650px の幅で切り分けて2つの場面にする
  - 足した動き（hakobu_motion.py）: アナウンス・ギョロ（外装を外した中の目）・イケボ・忍者・メニュー・カタッ・自己診断・夜勤スロット・ピタッ・確信度・経路再計算・職員認証・置いてけぼり・案内・プリン・優先度低・伝言・心拍・消せない・見張り。最初の1秒が止まった絵にならないよう、動きの頭は _approach（右から来て止まる）か、最初から動くものにする。体が左右反転している間は画面の字が鏡文字になるので、字を出さない
  - ElevenLabs の自作の声は、いまのプランでは10個が上限（2026-10-24 に 10/10）。新しい人は常連の声を借りる（中村さん＝槙原さんの声・森田茂さん＝田中先生の声。同じ話に本人が出ない組み合わせにする）。足した声: ハコブ_イケボ（第17話）・森田久美子
  - 背景の絵は縦長 4:5 で頼む（第52話は横長の白黒で届き、作り直してもらった）。届いた絵は大きさ（1122×1402 前後）を確かめてから使う
  - 30秒を超えた3本（第60・89・90話）は、話の筋に要らない文と場面を抜いて30秒以内にした
  - **吹き出しを途中で切らない**（2026-10-24 エイイチの指摘「吹き出しってもっと綺麗に切り取れないの？」「見切れているけど？」）。mkshort はコマを `_制作ベース/bubblefit.py` の framed で切る: ①切り抜きの辺にかかる吹き出しは、少しだけなら辺を狭めて外し、大きく削るなら広げて入れる（コマの枠の外へは広げない）②それでもコマの枠からはみ出す吹き出し（枠を突き抜けて描かれたもの）は、形のまま上に重ねる（2割近くが切り抜きに入っているものだけ）。吹き出しは「白い所の中に、小さな字の粒が行・列にそろって並ぶもの」で見つける。ページの外に開いた吹き出しは、ページの端の枠を閉じてから探す
  - 隣の人の吹き出しが重なって出るときは、spec の場面に `skip=[(x, y)]`（その吹き出しの中の点）を書く。拾えない吹き出しは `bub=[(x, y)]`、今までどおり切るだけにしたい場面は `fit=False`（画面の拡大など）
  - 書き出す前に、全部のコマを並べた一覧（framed の結果）を見て、切れた吹き出しや余計な吹き出しがないか確かめる
  - **ショートの X は自動で予約する**（2026-10-24 エイイチの指示「ショートも X に出す」「19:30 が重なったら動画は 20:00」）。ショートを作った会話で、X の文を r2r2r-content の `sns/SHORT_ep{N}_sns_posts.json`（`posts.x_template`、`{url}` がショートの URL に替わる・話題のタグ2つ・エイイチの話し方）に置いて push する。エイイチが YouTube Studio で公開予約すると、予定のタスク「ショートのX予約をYouTubeに合わせる」（毎日 7:47・17:47）が `youtube_videos.json` の `manga_ep`（説明文の manga/ep{N}。youtube_schedule.py が拾う）で結びつけ、公開の時刻で X の予定表に入れる。説明文の「マンガの全話」の URL は消さない。YouTube の公開予約は、ショート専用は平日 12:30（2026-10-24 の20本。表は KIOXIA の `_ショート専用/公開予約の表_20261024.txt`）。LinkedIn は手で出す
  - 重ねる吹き出しは、まわりの絵ごと切り抜かない（2026-10-24 エイイチの指摘「切り抜きが汚い・ガサガサ」）。吹き出しの中（白と字）だけを取り、形をなめらかにして、ふちの線（約2.7px）を4倍の細かさで描き直してから縮める。コマの枠は、枠の線が2本（となりのコマの枠と、このコマの枠）見つかったときは内側の1本にそろえ、上に描く枠と重ねて1本にする
```

更新履歴に足した行です。

```
- 2026-10-24（87回目）: 重ねた吹き出しのふちがガサガサ・枠が2重になっていた（エイイチの指摘）。bubblefit.py で吹き出しのふちを描き直し、枠を1本にそろえた。20本を書き出し直した
- 2026-10-24（86回目）: マンガショートの吹き出しが途中で切れていた（エイイチの指摘）。bubblefit.py を足し、mkshort が吹き出しを切らずにコマを切るようにした。20本を書き出し直した
- 2026-10-24（85回目）: ショート専用のマンガショートを20本作った（第7〜98話から20話）。9-0c に、台本の組み方（マンガショート_台本_20261024/）・字幕のページ分け（||）・横長のコマの切り分け・足した動き20個・声の上限（10個）の扱い・背景の大きさの確認・30秒以内への詰め方を足した
```

`_制作ベース` の `hakobu_motion.py` と `マンガショート_mkshort.py` は新しい版に入れ替え、前の版と video_rules.md の前の版は `_制作ベース/_旧版/置き換え前_20261024_ショート20話/` に残しました。`youtube_schedule.py` の前の版は `_制作ベース/_旧版/youtube_schedule_置き換え前_20261024.py` です。


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

- 20本の声の読み方と間は、耳で通しては確かめていない。書き出した動画のコマを並べて目で見ただけ。投稿の前に通しで見てもらう
- X の31本の文は、まだ見てもらっていない。予定表に入れるのは OK をもらったものだけ、という決まりがあるので、予約の一覧（`sns/schedule_preview.md`）で確かめてもらう
- 第90話は30.1秒で、30秒をわずかに超えている
- KIOXIA の `_ショート専用/_確認_20261024/` に、受け渡しに使った tgz と確認用の画像が残っている（こちらからは消せない）
- 公開後、ショートの「視聴を継続」の割合（作り直す前は19.0%）が上がるか。吹き出しが切れていた版と比べることはできないので、話ごとの差を見て、続けて見てもらえた話の作りを次に生かす


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

ファイルの置き場所は、`_制作ベース/` と `_ショート専用/` が iCloud の `PixelstoMotors/デジタルツイン学習R2R2R/動画/` の下、`tools/` と `docs/` と `sns/` が GitHub の `PixelstoMotors/r2r2r-content`、書き出しの補助のスクリプトはクラウドの作業環境（`/home/claude/w/`）です。

### _制作ベース/bubblefit.py

吹き出しを見つけ、切り抜きの範囲を合わせ、はみ出す吹き出しをふちを描き直して重ねる（新しく作った）

```python
"""マンガのコマの切り抜きを、吹き出しを途中で切らない位置に直す（2026-10-24）
fit(comic_rgb, crop) -> 直した crop
1. 切り抜きの外側にあるコマの枠（黒い線）を探し、そこまでを動かせる範囲にする
2. 白い所の連結成分のうち、中に文字の粒が並ぶものを吹き出しとみなす
3. 切り抜きの辺にかかる吹き出しは、半分以上が中にあれば広げて入れ、少しだけなら狭めて外す
"""
import numpy as np
from scipy import ndimage

def _panel(gray, crop):
    """切り抜きの辺の近く（±45px）で、いちばん長く黒い行・列をコマの枠とみなす。見つからなければ切り抜きの辺"""
    H, W = gray.shape
    x0, y0, x1, y1 = crop
    dark = gray < 90
    def best(axis, at, a, b, outward):
        rng = range(max(0, at - 45), min((H if axis == 0 else W), at + 45))
        sc = [(dark[v, a:b].mean() if axis == 0 else dark[a:b, v].mean(), v) for v in rng]
        hits = [v for c, v in sc if c > 0.4]
        if not hits: return (at, False)
        # 2本（となりのコマの枠と、このコマの枠）あるときは、コマの内側の1本を使う
        return ((max(hits) if outward < 0 else min(hits)), True)
    top = best(0, y0, x0 + 20, x1 - 20, -1); bot = best(0, y1, x0 + 20, x1 - 20, 1)
    lef = best(1, x0, y0 + 20, y1 - 20, -1); rig = best(1, x1, y0 + 20, y1 - 20, 1)
    _panel.found = (lef[1], top[1], rig[1], bot[1])
    return lef[0], top[0], rig[0], bot[0]

def _closed(gray):
    """ページの外の余白とコマの枠の外側を黒で囲み、枠からはみ出して余白に開いた吹き出しを閉じる"""
    g = gray.copy(); H, W = g.shape
    dark = g < 90
    cols = np.where(dark.mean(0) > 0.35)[0]; rows = np.where(dark.mean(1) > 0.35)[0]
    if len(cols) and cols.min() < 60: g[:, max(0, cols.min() - 1):cols.min() + 2] = 0       # ページの端に近い枠だけ
    if len(cols) and cols.max() > W - 60: g[:, cols.max() - 1:min(W, cols.max() + 2)] = 0
    if len(rows) and rows.min() < 60: g[max(0, rows.min() - 1):rows.min() + 2, :] = 0
    if len(rows) and rows.max() > H - 60: g[rows.max() - 1:min(H, rows.max() + 2), :] = 0
    return g

def bubbles(gray, box, want_mask=False):
    L, T, R, B = box
    sub = gray[T:B, L:R]
    white = sub > 200
    lab, n = ndimage.label(white)
    if n == 0: return []
    sizes = ndimage.sum(np.ones_like(lab), lab, range(1, n + 1))
    bg = 1 + int(np.argmax(sizes))
    out = []
    for i, sl in enumerate(ndimage.find_objects(lab), 1):
        if i == bg or sl is None: continue
        h, w = sl[0].stop - sl[0].start, sl[1].stop - sl[1].start
        area = sizes[i - 1]
        if area < 2500 or h < 40 or w < 40: continue
        reg = ndimage.binary_fill_holes(lab[sl] == i)
        if reg.sum() / (h * w) < 0.35: continue
        inner = reg & ~(lab[sl] == i)                   # 中にある黒い粒（文字）
        il, k = ndimage.label(inner)
        if k < 4: continue
        if inner.sum() / reg.sum() > 0.30: continue    # 黒っぽすぎる所（髪・服の模様）は除く
        objs = ndimage.find_objects(il)
        small = [o for o in objs if (o[0].stop - o[0].start) < 70 and (o[1].stop - o[1].start) < 70]
        big = [o for o in objs if (o[0].stop - o[0].start) >= 70 or (o[1].stop - o[1].start) >= 70]
        if len(small) < 6 or big: continue             # 文字は小さな粒が並ぶ。長い線（服のしわ・聴診器）があれば吹き出しではない
        if h > 520 or w > 760 or (w > 480 and h > 300): continue   # コマ全体を拾わない
        hs = sorted((o[0].stop - o[0].start) for o in small)
        if hs[len(hs) // 2] < 6: continue              # 点や網かけだけ
        cy = np.array([(o[0].start + o[0].stop) / 2 for o in small]); cx = np.array([(o[1].start + o[1].stop) / 2 for o in small])
        def rows(c):                                   # 中心がそろった粒の数（文字の行・列）
            c = np.sort(c); best = 1; j = 0
            for k in range(len(c)):
                while c[k] - c[j] > 12: j += 1
                best = max(best, k - j + 1)
            return best
        if max(rows(cy), rows(cx)) < 4: continue
        if want_mask:
            m = np.pad(reg, 8)                          # 吹き出しの中（白と字）。ふちの線は重ねるときに描き直す
            out.append(((L + sl[1].start - 8, T + sl[0].start - 8, L + sl[1].stop + 8, T + sl[0].stop + 8), m))
        else:
            out.append((L + sl[1].start, T + sl[0].start, L + sl[1].stop, T + sl[0].stop))
    return out

def fit(img, crop, pad=10):
    gray = np.asarray(img.convert("L")).astype(np.int16)
    box = _panel(gray, crop)
    x0, y0, x1, y1 = crop
    L, T, R, B = box
    fL, fT, fR, fB = _panel.found
    # 枠の線が見つかった辺は、線の外側にそろえる（線は切り抜きに入れ、上に描く枠と重ねて1本にする）
    x0 = L - 3 if fL else max(x0, L + 2); y0 = T - 3 if fT else max(y0, T + 2)
    x1 = R + 4 if fR else min(x1, R - 2); y1 = B + 4 if fB else min(y1, B - 2)
    L, T, R, B = L - 5, T - 5, R + 6, B + 6
    for _ in range(3):
        changed = False
        for bx0, by0, bx1, by1 in bubbles(gray, (L + 3, T + 3, R - 3, B - 3)):
            ix0, iy0, ix1, iy1 = max(x0, bx0), max(y0, by0), min(x1, bx1), min(y1, by1)
            if ix0 >= ix1 or iy0 >= iy1: continue
            if bx0 >= x0 and by0 >= y0 and bx1 <= x1 and by1 <= y1: continue
            frac = (ix1 - ix0) * (iy1 - iy0) / ((bx1 - bx0) * (by1 - by0))
            if frac >= 0.35:                            # 広げて入れる
                nx0, ny0, nx1, ny1 = min(x0, bx0 - pad), min(y0, by0 - pad), max(x1, bx1 + pad), max(y1, by1 + pad)
                x0, y0, x1, y1 = max(nx0, L + 2), max(ny0, T + 2), min(nx1, R - 2), min(ny1, B - 2)
            else:                                       # 少しだけかかる → その辺を狭めて外す
                cand = []
                if bx0 > x0: cand.append(("r", x1 - (bx0 - pad)))
                if bx1 < x1: cand.append(("l", (bx1 + pad) - x0))
                if by0 > y0: cand.append(("b", y1 - (by0 - pad)))
                if by1 < y1: cand.append(("t", (by1 + pad) - y0))
                if not cand: continue
                side, amt = min(cand, key=lambda c: c[1])
                if amt > 0.15 * ((x1 - x0) if side in "lr" else (y1 - y0)):   # 大きく削るくらいなら広げて入れる
                    nx0, ny0, nx1, ny1 = min(x0, bx0 - pad), min(y0, by0 - pad), max(x1, bx1 + pad), max(y1, by1 + pad)
                    x0, y0, x1, y1 = max(nx0, L + 2), max(ny0, T + 2), min(nx1, R - 2), min(ny1, B - 2)
                    changed = True; continue
                if side == "r": x1 = bx0 - pad
                if side == "l": x0 = bx1 + pad
                if side == "b": y1 = by0 - pad
                if side == "t": y0 = by1 + pad
            changed = True
        if not changed: break
    return (int(x0), int(y0), int(x1), int(y1))

def all_bubbles(img):
    gray = _closed(np.asarray(img.convert("L")).astype(np.int16))
    H, W = gray.shape
    return bubbles(gray, (0, 0, W, H))

def _clean_bubble(rgb, box, m, out_w=1.4, in_w=1.3, k=4):
    """吹き出しをきれいに切り出す: 中（白と字）はそのまま、ふちの線は形をなめらかにして描き直す。
    4倍の細かさで形と線を作ってから縮め、ふちのギザギザ（ガサガサ）を出さない"""
    from PIL import Image
    from scipy.ndimage import gaussian_filter, distance_transform_edt
    piece = np.asarray(rgb.crop(box).convert("RGB")).astype(np.float32)
    h, w = piece.shape[:2]
    m = m[:h, :w].astype(np.float32)
    big = np.kron(m, np.ones((k, k), np.float32))
    inner = gaussian_filter(big, 2.6 * k) > 0.5                  # 中の形をなめらかに
    d_out = distance_transform_edt(~inner) / k                   # 元の大きさでの、外側への距離
    d_in = distance_transform_edt(inner) / k
    ink_hi = ((~inner) & (d_out <= out_w)) | (inner & (d_in <= in_w))   # 線: ふちの外 out_w・内 in_w
    in_hi = inner & (d_in > in_w)
    def down(a): return a.reshape(h, k, w, k).mean((1, 3))
    ink_a, in_a = down(ink_hi.astype(np.float32)), down(in_hi.astype(np.float32))
    inkc = np.array([13, 13, 13], np.float32)
    a = np.clip(ink_a + in_a, 0, 1)
    col = (piece * in_a[..., None] + inkc * ink_a[..., None]) / np.maximum(a, 1e-6)[..., None]
    out = np.dstack([np.clip(col, 0, 255), a * 255])
    return Image.fromarray(out.astype(np.uint8), "RGBA")

def framed(img, crop, extra=(), pad=10, skip=()):
    """吹き出しを切らないコマの画像を返す。(RGBA の画像, 直した crop)
    コマの中で動かせるだけ crop を動かし、それでもコマの枠の外へはみ出す吹き出しは、形のまま上に重ねる。
    extra: 拾えなかった吹き出しの中の点 [(x, y), …]（その白い所を吹き出しとして足す）"""
    from PIL import Image, ImageDraw
    gray = _closed(np.asarray(img.convert("L")).astype(np.int16))
    H, W = gray.shape
    c = fit(img, crop, pad)
    bl = bubbles(gray, (0, 0, W, H), want_mask=True)
    if extra:
        lab, _ = ndimage.label(gray > 200)
        for x, y in extra:
            i = lab[y, x]
            if not i: continue
            sl = ndimage.find_objects((lab == i).astype(np.int32))[0]
            reg = ndimage.binary_fill_holes(lab[sl] == i)
            m = np.pad(reg, 8)
            bl.append(((sl[1].start - 8, sl[0].start - 8, sl[1].stop + 8, sl[0].stop + 8), m))
    x0, y0, x1, y1 = c
    over = []
    for (bx0, by0, bx1, by1), m in bl:
        ix0, iy0, ix1, iy1 = max(x0, bx0), max(y0, by0), min(x1, bx1), min(y1, by1)
        if ix0 >= ix1 or iy0 >= iy1: continue
        if bx0 >= x0 and by0 >= y0 and bx1 <= x1 and by1 <= y1: continue
        frac = (ix1 - ix0) * (iy1 - iy0) / ((bx1 - bx0) * (by1 - by0))
        mm = m[:by1 - by0, :bx1 - bx0]
        inside = mm[max(0, y0 - by0):max(0, y1 - by0), max(0, x0 - bx0):max(0, x1 - bx0)].sum()
        if any(bx0 <= x <= bx1 and by0 <= y <= by1 for x, y in skip): continue   # 台本で「のせない」と決めた吹き出し
        if inside / max(1, mm.sum()) >= 0.19:            # 吹き出しの2割近くが切り抜きに入っているものだけ、形のままのせる（隣のコマの吹き出しはのせない）
            over.append(((bx0, by0, bx1, by1), m))
    ux0 = min([x0] + [b[0][0] for b in over]); uy0 = min([y0] + [b[0][1] for b in over])
    ux1 = max([x1] + [b[0][2] for b in over]); uy1 = max([y1] + [b[0][3] for b in over])
    rgb = img.convert("RGB")
    can = Image.new("RGBA", (ux1 - ux0, uy1 - uy0), (0, 0, 0, 0))
    can.paste(rgb.crop(c), (x0 - ux0, y0 - uy0))
    ImageDraw.Draw(can).rectangle([x0 - ux0, y0 - uy0, x1 - ux0 - 1, y1 - uy0 - 1], outline=(13, 13, 13, 255), width=7)
    for (bx0, by0, bx1, by1), m in over:
        can.alpha_composite(_clean_bubble(rgb, (bx0, by0, bx1, by1), m), (bx0 - ux0, by0 - uy0))
    return can, c
```

### _制作ベース/マンガショート_mkshort.py

台本から1本のショートを組む。字幕のページ分け（add_sub）と、bubblefit で切るコマ（framed_panel）を足した

```python
"""マンガのショート（第79話の型）を、話ごとの台本 spec から組む。
使い方: python3 mkshort.py spec_ep81.py [--still 秒,秒] → out_{名前}.mp4
spec に書くもの（spec_ep81.py を見本に）:
  NAME, EP（話の番号）, HEAD（見出し2行）, COMIC（マンガの画像）, SCENES（場面のリスト）
  場面: dict(kind="op", video=..., dur=秒, bubble=["1行目", "2行目"], bubble_until=秒, lines=[...], words=[...])
        dict(kind="panel", crop=(x0,y0,x1,y1), scale=1.55, lines=[...], words=[...], slam=dict(text, at, y), min=秒)
        dict(kind="end", rows=[(文, 大きさ, "ink"|"orange"|"gray", 出る秒)], dur=秒)
  lines: dict(v="voice/ハコブ_01.mp3", who="hk"|"ns", sub="字幕", at=秒（op のときだけ・場面の頭から）, cut=(a,b), db=0, small=True)
         panel では lines を順に読み、場面の長さは声の長さから決まる（min より短くならない）
  words: dict(text, at, x, y, size=150, color="ink"|"orange", rot=0, until=秒)
  se: dict(at=秒（場面の頭から）, f="se/se_don.mp3", db=-6)
"""
import os, sys, math, subprocess, importlib.util
import numpy as np
from scipy import ndimage
from PIL import Image, ImageDraw, ImageFont

W, H, FPS, SR = 1080, 1920, 30, 48000
PAPER = (245, 245, 240); INK = (13, 13, 13); GRAY = (136, 136, 136); ORANGE = (196, 71, 31); BLUE = (120, 200, 255)
COL = {"ink": INK, "orange": ORANGE, "gray": GRAY}
FONT = "/usr/share/fonts/opentype/noto/NotoSansCJK-Black.ttc"
F = lambda size: ImageFont.truetype(FONT, size, index=0)
VX, VY, VW, VH = 0, 466, 1080, 974
SUB_Y = 1488
OP_CROP = (0, 172, 848, 937)
HERE = os.path.dirname(os.path.abspath(__file__))
POP = os.path.join(HERE, "base/se_pop.wav")

spec_path = sys.argv[1]
sp = importlib.util.spec_from_file_location("spec", spec_path); S = importlib.util.module_from_spec(sp); sp.loader.exec_module(S)
os.chdir(os.path.dirname(os.path.abspath(spec_path)))

def load_audio(path, af=None):
    r = subprocess.run(["ffmpeg", "-v", "error", "-i", path] + (["-af", af] if af else []) + ["-f", "f32le", "-ac", "1", "-ar", str(SR), "-"], capture_output=True)
    return np.frombuffer(r.stdout, np.float32).copy()
def voice(l):
    a = load_audio(l["v"], "highpass=f=450,lowpass=f=3200" if l.get("phone") else None)   # phone=True: 電話の向こうの声
    if l.get("cut"):
        a0, a1 = l["cut"]; a = np.concatenate([a[:int(a0 * SR)], a[int(a1 * SR):]])
    e = np.abs(a); idx = np.where(e > e.max() * 0.06)[0]
    a = a[max(0, idx[0] - int(0.02 * SR)):idx[-1] + int(0.08 * SR)]     # 前後の無音を落とす
    return a


def add_sub(st, length, l, end=None):
    """字幕を「||」でページに分け、声の長さを字数で割り振って順に出す（2026-10-24）"""
    pages = l["sub"].split("||")
    n = [max(1, len(p.replace("|", "").replace("【", "").replace("】", ""))) for p in pages]
    tot, acc = sum(n), 0
    for k, (pg, c) in enumerate(zip(pages, n)):
        a = st + length * acc / tot; acc += c
        last = k == len(pages) - 1
        SUBS.append((a, (end if last else st + length * acc / tot), pg, l["who"], l.get("small")))

# ---------------- 時刻を決める ----------------
T = 0.0
EVENTS = []          # (開始, 終了, 場面)
AUDIO = []           # (時刻, 波形, dB)
SUBS = []            # (開始, 終了, 字幕, 誰, 小さい)
for sc in S.SCENES:
    t0 = T
    if sc["kind"] == "op":
        dur = sc["dur"]
        for l in sc.get("lines", []):
            a = voice(l); AUDIO.append((t0 + l["at"], a, l.get("db", 0)))
            add_sub(t0 + l["at"], len(a) / SR, l, min(t0 + l["at"] + len(a) / SR + 0.5, t0 + dur))
    elif sc["kind"] == "panel":
        cur = t0 + 0.15
        for l in sc.get("lines", []):
            a = voice(l)
            st = cur + l.get("gap", 0)
            AUDIO.append((st, a, l.get("db", 0)))
            if l.get("with"):                      # 同時に言う人
                for w2 in l["with"]:
                    a2 = voice(dict(v=w2)); AUDIO.append((st + 0.025, a2, l.get("db", 0) - 2))
            add_sub(st, len(a) / SR, l, None)
            cur = st + len(a) / SR + 0.3
        dur = max(sc.get("min", 1.8), cur - t0 + 0.35)
    else:
        dur = sc.get("dur", 4.2)
    for se in sc.get("se", []):
        AUDIO.append((t0 + se["at"], load_audio(se["f"]), se.get("db", -6)))
    if sc["kind"] in ("panel", "end"):
        AUDIO.append((t0, load_audio(POP), -13))
    if sc["kind"] == "end":
        for txt, sz, col, d0 in sc["rows"]:
            if d0 > 0 and sz >= 90: AUDIO.append((t0 + d0, load_audio(POP), -12)); break
    EVENTS.append((t0, t0 + dur, sc)); T += dur
TOTAL = T
# 字幕の終わり: 次の字幕か場面の終わりまで
SUBS.sort(key=lambda s: s[0])
fixed = []
for i, (a, b, txt, who, sm) in enumerate(SUBS):
    end_scene = next(e1 for e0, e1, _ in EVENTS if e0 <= a < e1)
    nxt = SUBS[i + 1][0] if i + 1 < len(SUBS) else TOTAL
    fixed.append((a, min(end_scene, nxt) if b is None else min(b, nxt), txt, who, sm))
SUBS = fixed

# ---------------- 部品 ----------------
def text_img(txt, size, fill=INK, stroke=0, stroke_fill=(255, 255, 255), hl=ORANGE):
    f = F(size); parts, cur, on = [], "", False
    for ch in txt:
        if ch in "【】":
            if cur: parts.append((cur, on)); cur = ""
            on = ch == "【"; continue
        cur += ch
    if cur: parts.append((cur, on))
    plain = "".join(p for p, _ in parts); bb = f.getbbox(plain, stroke_width=stroke)
    im = Image.new("RGBA", (bb[2] - bb[0] + 2 * stroke + 8, size + 2 * stroke + int(size * 0.35)), (0, 0, 0, 0))
    d = ImageDraw.Draw(im); x = stroke + 4 - bb[0]
    for p, on in parts:
        d.text((x, stroke), p, font=f, fill=(hl if on else fill), stroke_width=stroke, stroke_fill=stroke_fill); x += f.getlength(p)
    if stroke:                                  # 「。」や「゜」の穴（透けて白く見える）をふち色で埋める
        a = np.array(im)
        solid = a[..., 3] > 250
        holes = ndimage.binary_dilation(ndimage.binary_fill_holes(solid) & ~solid, iterations=2) & ~solid
        al = a[..., 3:4].astype(np.float32) / 255           # 穴とそのふちの半透明を、ふち色の上に重ねる
        bgc = np.array(stroke_fill[:3], np.float32)
        a[holes, :3] = (a[holes, :3] * al[holes] + bgc * (1 - al[holes])).astype(np.uint8)
        a[holes, 3] = 255
        im = Image.fromarray(a)
    return im
def paste_c(base, im, cx, cy, scale=1.0, rot=0.0):
    if scale != 1.0: im = im.resize((max(1, int(im.width * scale)), max(1, int(im.height * scale))), Image.LANCZOS)
    if rot: im = im.rotate(rot, resample=Image.BICUBIC, expand=True)
    base.alpha_composite(im, (int(cx - im.width / 2), int(cy - im.height / 2)))
def ease(u): u = min(max(u, 0), 1); return u * u * (3 - 2 * u)
def pop(t, t0, dur=0.18):
    u = (t - t0) / dur
    if u <= 0: return 0
    if u >= 1: return 1 + 0.08 * math.exp(-(t - t0 - dur) * 12) * math.cos((t - t0 - dur) * 30)
    return 1.15 * math.sin(u * math.pi / 2)
def slam(t, t0, dur=0.14, big=2.6):
    u = (t - t0) / dur
    if u <= 0: return None
    if u >= 1: return 1 + 0.05 * math.exp(-(t - t0 - dur) * 10) * math.cos((t - t0 - dur) * 35)
    return big + (1 - big) * ease(u)
def shake(t, t0, amp, dec=9, hz=23):
    if t < t0: return 0
    v = t - t0; return amp * math.exp(-v * dec) * math.sin(2 * math.pi * hz * v)

logo = Image.open(os.path.join(HERE, "logo_dark.png")).convert("RGBA")
logo = logo.resize((int(logo.width * 54 / logo.height), 54), Image.LANCZOS)
BASE = Image.new("RGBA", (W, H), PAPER + (255,)); BASE.alpha_composite(logo, (60, 175))
for k, line in enumerate(S.HEAD):
    BASE.alpha_composite(text_img(line, 86), (52, 228 + k * 96))
BASE.alpha_composite(text_img(f"4コママンガ『ハコブくん』第{S.EP}話より（つくり話・絵は生成AIで作成）", 24, fill=GRAY), (60, VY + VH + 8))
comic = Image.open(S.COMIC).convert("RGB")
_pc = {}
_fr = {}
sys.path.insert(0, HERE)
from bubblefit import framed                     # 吹き出しを切らない切り抜き（2026-10-24）
def framed_panel(sc):
    """コマの画像（RGBA）。sc["fit"]=False なら今までどおり crop のまま。sc["bub"]=[(x, y)] で拾えない吹き出しを足す"""
    k = (sc["crop"], tuple(sc.get("bub", ())), tuple(sc.get("skip", ())), sc.get("fit", True))
    if k not in _fr:
        if sc.get("fit", True):
            _fr[k] = framed(comic, sc["crop"], sc.get("bub", ()), skip=sc.get("skip", ()))[0]
        else:
            c = comic.crop(sc["crop"]).convert("RGBA")
            ImageDraw.Draw(c).rectangle([0, 0, c.width - 1, c.height - 1], outline=INK, width=6); _fr[k] = c
    return _fr[k]
def panel(sc, scale):
    k = (sc["crop"], tuple(sc.get("bub", ())), tuple(sc.get("skip", ())), round(scale, 3))
    if k not in _pc:
        c = framed_panel(sc); _pc[k] = c.resize((int(c.width * scale), int(c.height * scale)), Image.LANCZOS)
    return _pc[k]
_vid = {}
def vframe(path, t):
    if path not in _vid:
        r = subprocess.run(["ffmpeg", "-v", "error", "-i", path, "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], capture_output=True)
        _vid[path] = np.frombuffer(r.stdout, np.uint8).reshape(-1, 1056, 848, 3)
    v = _vid[path]; i = min(max(int(round(t * FPS)), 0), len(v) - 1)
    return Image.fromarray(v[i]).crop(OP_CROP).resize((VW, VH), Image.LANCZOS).convert("RGBA")
def bubble(lines, size=60, side="L"):
    f = F(size); tw = max(f.getlength(l.replace("【", "").replace("】", "")) for l in lines); th = len(lines) * size * 1.25
    pw, ph = int(tw + 70), int(th + 50)
    im = Image.new("RGBA", (pw + 60, ph + 10), (0, 0, 0, 0)); d = ImageDraw.Draw(im)
    ox = 60 if side == "L" else 0                  # 吹き出しの体の左端（しっぽは反対側に）
    d.rounded_rectangle([ox, 4, ox + pw, 4 + ph], radius=46, fill=(255, 255, 255, 255), outline=INK, width=6)
    if side == "L":
        d.polygon([(0, ph * 0.72), (66, ph * 0.45), (66, ph * 0.78)], fill=(255, 255, 255, 255))
        d.line([(64, ph * 0.45), (0, ph * 0.72), (64, ph * 0.80)], fill=INK, width=6, joint="curve")
    else:
        X = pw + 60
        d.polygon([(X, ph * 0.72), (pw - 6, ph * 0.45), (pw - 6, ph * 0.78)], fill=(255, 255, 255, 255))
        d.line([(pw - 4, ph * 0.45), (X, ph * 0.72), (pw - 4, ph * 0.80)], fill=INK, width=6, joint="curve")
    y = 29
    for l in lines:
        x = ox + 35; on = False
        for ch in l:
            if ch in "【】": on = ch == "【"; continue
            d.text((x, y), ch, font=f, fill=(ORANGE if on else INK)); x += f.getlength(ch)
        y += size * 1.25
    return im
_wc = {}
def word(w):
    k = (w["text"], w.get("size", 150), w.get("color", "ink"))
    if k not in _wc: _wc[k] = text_img(w["text"], w.get("size", 150), fill=COL[w.get("color", "ink")], stroke=12, stroke_fill=(255, 255, 255))
    return _wc[k]
import functools
@functools.lru_cache(maxsize=None)
def subtitle(txt, who, small):
    if small:
        return text_img(txt, 30, fill=BLUE if who == "hk" else (255, 255, 255), stroke=5, stroke_fill=INK)
    rows = txt.split("|")                 # 「|」で2行に分ける（1行は13字まで）
    plain = lambda r: len(r.replace("【", "").replace("】", ""))
    if len(rows) == 1 and plain(txt) > 13:     # 長いときは真ん中に近い「、」「。」で自動で折る
        cands = [i + 1 for i, ch in enumerate(txt[:-1]) if ch in "、。！？"]
        if cands:
            k = min(cands, key=lambda i: abs(plain(txt[:i]) - plain(txt) / 2)); rows = [txt[:k], txt[k:]]
    ims = [text_img(r, 66, fill=BLUE if who == "hk" else (255, 255, 255), stroke=11, stroke_fill=INK, hl=(255, 140, 90)) for r in rows]
    if len(ims) == 1: return ims[0]
    out = Image.new("RGBA", (max(i.width for i in ims), sum(i.height - 14 for i in ims) + 14), (0, 0, 0, 0))
    y = 0
    for i in ims: out.alpha_composite(i, ((out.width - i.width) // 2, y)); y += i.height - 14
    return out

def frame(t):
    img = BASE.copy(); vis = Image.new("RGBA", (VW, VH), PAPER + (255,)); cam = (0, 0)
    t0, t1, sc = next(((a, b, s) for a, b, s in EVENTS if a <= t < b), EVENTS[-1])
    lt = t - t0; u = lt / (t1 - t0)
    if sc["kind"] == "op":
        vis = vframe(sc["video"], lt + sc.get("src_t0", 0))
        b0 = sc.get("bubble_from", 0.0)
        if sc.get("bubble") and b0 <= lt < sc.get("bubble_until", 99):
            if "_bub" not in sc: sc["_bub"] = bubble(sc["bubble"])
            k = pop(lt, b0, 0.2)
            if k: paste_c(vis, sc["_bub"], 30 + sc["_bub"].width * k / 2, 105, scale=k)
        for bb in sc.get("bubbles", []):          # 左右から別々の人がしゃべる（2026-10-20）
            if bb["frm"] <= lt < bb["until"]:
                if "_img" not in bb: bb["_img"] = bubble(bb["lines"], side=bb.get("side", "L"))
                k = pop(lt, bb["frm"], 0.2)
                if k:
                    w = bb["_img"].width
                    cx = (30 + w * k / 2) if bb.get("side", "L") == "L" else (VW - 30 - w * k / 2)
                    paste_c(vis, bb["_img"], cx, bb.get("y", 105), scale=k)
    elif sc["kind"] == "panel":
        fw, fh = framed_panel(sc).size
        s0 = sc.get("scale") or min(1040 / fw, 930 / fh)
        s = s0 * (0.96 + 0.04 * ease(u))
        paste_c(vis, panel(sc, s), VW / 2, VH / 2 + sc.get("dy", 0))
        if sc.get("slam"):
            sl = sc["slam"]
            if "_img" not in sl: sl["_img"] = text_img(sl["text"], sl.get("size", 160), fill=COL[sl.get("color", "ink")], stroke=14, stroke_fill=(255, 255, 255))
            k = slam(lt, sl["at"])
            if k: paste_c(vis, sl["_img"], VW / 2, sl.get("y", 200), scale=k, rot=sl.get("rot", -4))
            cam = (shake(lt, sl["at"] + 0.14, 14), shake(lt, sl["at"] + 0.14, 9, 9, 29))
    else:
        y = 190
        for txt, sz, col, d0 in sc["rows"]:
            k = pop(lt, d0, 0.16)
            if k: paste_c(vis, text_img(txt, sz, fill=COL[col]), VW / 2, y + sz / 2, scale=k)
            y += sz * 1.45 + (24 if sz >= 70 else 0)
    for w in sc.get("words", []):
        if w["at"] <= lt < w.get("until", 99):
            paste_c(vis, word(w), w["x"], w["y"], scale=pop(lt, w["at"], 0.12) or 0.01, rot=w.get("rot", 0))
    if cam != (0, 0):
        vis = vis.transform(vis.size, Image.AFFINE, (1, 0, -cam[0], 0, 1, -cam[1]), resample=Image.BILINEAR, fillcolor=PAPER + (255,))
    img.alpha_composite(vis, (VX, VY))
    for a, b, txt, who, sm in SUBS:
        if a <= t < b:
            paste_c(img, subtitle(txt, who, sm), W / 2, SUB_Y + 60 + max(0, (subtitle(txt, who, sm).height - 105) // 2), scale=min(pop(t, a, 0.12) or 0.01, 1.06))
    return img.convert("RGB")

if __name__ == "__main__":
    if "--still" in sys.argv:
        for s in sys.argv[sys.argv.index("--still") + 1].split(","):
            frame(float(s)).save(f"still_{S.NAME}_{s}.png")
        print("場面:", [(round(a, 2), round(b, 2), s["kind"]) for a, b, s in EVENTS]); sys.exit(0)
    n = int(TOTAL * FPS)
    p = subprocess.Popen(["ffmpeg", "-v", "error", "-y", "-f", "rawvideo", "-pix_fmt", "rgb24", "-s", f"{W}x{H}", "-r", str(FPS),
                          "-i", "-", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "20", f"video_{S.NAME}.mp4"], stdin=subprocess.PIPE)
    for i in range(n): p.stdin.write(np.asarray(frame(i / FPS)).tobytes())
    p.stdin.close(); p.wait()
    out = np.zeros(int((TOTAL + 1) * SR), np.float32)
    for t, a, db in AUDIO:
        i = int(t * SR); m = min(len(a), len(out) - i); out[i:i + m] += a[:m] * (10 ** (db / 20))
    pk = np.abs(out).max() or 1
    if pk > 0.95: out *= 0.95 / pk
    subprocess.run(["ffmpeg", "-v", "error", "-y", "-f", "f32le", "-ar", str(SR), "-ac", "1", "-i", "-", f"mix_{S.NAME}.wav"], input=out.tobytes())
    subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", f"video_{S.NAME}.mp4", "-i", f"mix_{S.NAME}.wav", "-c:v", "copy", "-c:a", "aac",
                    "-b:a", "192k", "-af", "loudnorm=I=-16:TP=-1.5:LRA=11", "-shortest", f"out_{S.NAME}.mp4"])
    print("できあがり:", f"out_{S.NAME}.mp4", round(TOTAL, 1), "秒")
```

### _制作ベース/hakobu_motion.py

冒頭のハコブくんの動き。末尾の「2026-10-24 追加の20話」が今回足した20個

```python
"""ハコブくんの短い動き（ショートの冒頭用）。FVの病室のコマ（g_05.png）の上で、体を動かし、顔とリングを描き替える。
使い方: python3 anim_kit.py [パターン名 ...]   （名前なしで全部）→ ハコブくん_{名前}.mp4
パターン: 大あわて / 落ちこむ / 固まる / 大喜び / なぐさめる
"""
import os, sys, subprocess
import numpy as np, cv2
from PIL import Image
import hakobu as hk

hk.BODY = "new"          # 頭まで欠けていない体（cut_new.png）。古い体（FVのコマの切り抜き）は頭の上が平らに切れて見えるので使わない
FPS, SUB = 30, 3         # SUB: 速く動く間の動きのぶれ（1コマに3回分を重ねる）

# ================= 顔の形を足す =================
_orig_eye = hk.eye_mask
def eye_mask(m, kind, box, side, dy):
    x0, y0, x1, y1 = box[0], box[1] + dy, box[2], box[3] + dy
    cx, cy, w, h = (x0 + x1) / 2, (y0 + y1) / 2, x1 - x0, y1 - y0
    if kind == "none":
        return
    if kind.startswith("text:") or kind == "line":
        if side > 0:
            return                                    # 画面全体に1回だけ描く
        L, R = hk.V_EYE_LEFT[0] - 14, hk.V_EYE_RIGHT[2] + 14
        T_, B_ = hk.V_EYE_LEFT[1] - 8, hk.V_MOUTH[3] + 4
        if kind == "line":
            ym = (T_ + B_) / 2
            hk._thick_line(m, [(L - 6, ym), (R + 6, ym)], 3.0); return
        rows = kind[5:].split("|")
        from PIL import ImageDraw, ImageFont
        Wd, Hd = int((R - L) * hk.SS), int((B_ - T_) * hk.SS)
        size = int(min(Hd / (len(rows) * 1.25), Wd / max(len(r) for r in rows) * 0.98))
        fnt = ImageFont.truetype("/usr/share/fonts/opentype/noto/NotoSansCJK-Black.ttc", size, index=0)
        im = Image.new("L", (Wd, Hd), 0); d = ImageDraw.Draw(im)
        y = (Hd - len(rows) * size * 1.2) / 2
        for r in rows:
            d.text(((Wd - fnt.getlength(r)) / 2, y), r, font=fnt, fill=255); y += size * 1.2
        im = np.array(im)[:, ::-1]                    # 顔の層はあとで左右反転されるので、先に反転しておく
        X0, Y0 = hk._p(L, T_)
        sub = m[Y0:Y0 + Hd, X0:X0 + Wd]
        np.maximum(sub, im[:sub.shape[0], :sub.shape[1]], out=sub); return
    if kind == "dot":
        cv2.circle(m, hk._p(cx, cy + 2), int(4.2 * hk.SS), 255, -1, cv2.LINE_AA); return
    if kind == "down":   # 下を向いた目（低く短い）
        a, b = hk._p(cx - w / 2, cy + h * 0.05), hk._p(cx + w / 2, cy + h * 0.5)
        im = Image.fromarray(m); from PIL import ImageDraw
        ImageDraw.Draw(im).rounded_rectangle([a, b], radius=w / 2 * hk.SS, fill=255); m[:] = np.array(im); return
    if kind == "x":      # ×の目
        r = w * 0.55
        hk._thick_line(m, [(cx - r, cy - r), (cx + r, cy + r)], 4.5)
        hk._thick_line(m, [(cx - r, cy + r), (cx + r, cy - r)], 4.5); return
    if kind == "heart":
        t = np.linspace(0, 2 * np.pi, 80)
        s = w * 0.05
        xs = cx + s * 16 * np.sin(t) ** 3
        ys = cy - s * (13 * np.cos(t) - 5 * np.cos(2 * t) - 2 * np.cos(3 * t) - np.cos(4 * t)) + 1
        cv2.fillPoly(m, [np.array([hk._p(x, y) for x, y in zip(xs, ys)], np.int32)], 255, cv2.LINE_AA); return
    return _orig_eye(m, kind, box, side, dy)
hk.eye_mask = eye_mask

_orig_mouth = hk.mouth_mask
def mouth_mask(kind):
    w, h = (hk.FX1 - hk.FX0) * hk.SS, (hk.FY1 - hk.FY0) * hk.SS
    if kind == "none":
        return np.zeros((h, w), np.uint8)
    if kind.startswith("wave"):
        m = np.zeros((h, w), np.uint8)
        ph = 0 if kind == "wave" else 1
        x0, x1 = hk.V_MOUTH[0] - 4, hk.V_MOUTH[2] + 4
        cy = (hk.V_MOUTH[1] + hk.V_MOUTH[3]) / 2 - 4
        pts = [(x0 + (x1 - x0) * i / 6, cy + (5 if (i + ph) % 2 else -5)) for i in range(7)]
        hk._thick_line(m, pts, 4.0); return m
    if kind.startswith("frown_tear"):   # への字の口＋涙（涙の下がり具合を名前の数字で）
        m = _orig_mouth("frown").copy()
        k = int(kind[len("frown_tear"):])
        ex = (hk.V_EYE_LEFT[0] + hk.V_EYE_LEFT[2]) / 2 - 2
        ey = hk.V_EYE_LEFT[3] + 4 + k
        r = 4.2
        cv2.circle(m, hk._p(ex, ey), int(r * hk.SS), 255, -1, cv2.LINE_AA)
        tri = np.array([hk._p(ex - r * 0.95, ey - 1), hk._p(ex + r * 0.95, ey - 1), hk._p(ex, ey - 9)], np.int32)
        cv2.fillPoly(m, [tri], 255, cv2.LINE_AA); return m
    return _orig_mouth(kind)
hk.mouth_mask = mouth_mask

# ================= 体と背景の準備 =================
bot = hk.Hakobu(mode="full")

def _clean_wheels(img):
    """タイヤのまわりだけ、切り抜きの白いふち（元の白い背景の残り）を消す。ほかの部分は触らない"""
    a = np.array(img)
    ys_, _ = np.nonzero(a[..., 3] > 10)
    y0 = int(ys_.max() - 0.13 * (ys_.max() - ys_.min()))        # タイヤのある高さ（体の下 13%）
    lum = a[..., :3].astype(np.float32).mean(2)
    reg = np.zeros(lum.shape, bool); reg[y0:] = True
    dark = (lum < 110) & (a[..., 3] > 128) & reg                  # タイヤと台座の黒
    near_dark = cv2.dilate(dark.astype(np.uint8), np.ones((11, 11), np.uint8)) > 0
    near_out = cv2.dilate((a[..., 3] < 20).astype(np.uint8), np.ones((9, 9), np.uint8)) > 0
    edge = reg & near_dark & near_out & (a[..., 3] > 0)
    light = ((lum > 120) & (a[..., 3] > 0)).astype(np.uint8)
    body_light = cv2.dilate(cv2.morphologyEx(light, cv2.MORPH_OPEN, np.ones((9, 9), np.uint8)), np.ones((3, 3), np.uint8)) > 0
    halo = edge & (light > 0) & ~body_light                       # 細い白っぽいふちだけ消す（太い白は体なので残す）
    a[halo, 3] = 0
    soft = edge & ~halo & (a[..., 3] < 250)                       # 半透明のふち → 色をタイヤの黒に寄せる
    a[soft, :3] = np.minimum(a[soft, :3], 55)
    return Image.fromarray(a)
bot.body = _clean_wheels(bot.body)
bg = np.array(Image.open(os.path.join(hk.ASSET_DIR, "g_05.png")).convert("RGB"))
Hh, Ww = bg.shape[:2]
# 体を画面に置く: ディスプレイの幅を古い体（180px）にそろえ、足もとを y=905・中心を x=470 に（FVのロボットと同じ立ち位置）
_ba = np.array(bot.body)[..., 3]; _bys, _bxs = np.nonzero(_ba > 10)
_sys, _sxs = np.nonzero(bot.screen_mask)
BSC = 180.0 / (_sxs.max() - _sxs.min())
BOX = 470 - BSC * (_bxs.min() + _bxs.max()) / 2
BOY = 905 - BSC * _bys.max()
def to_canvas(arr):
    """体の座標で描いた RGBA を、画面（Ww×Hh）の立ち位置へ"""
    return cv2.warpAffine(arr, np.float32([[BSC, 0, BOX], [0, BSC, BOY]]), (Ww, Hh), flags=cv2.INTER_LINEAR, borderValue=(0, 0, 0, 0))
body = Image.fromarray(to_canvas(np.array(bot.body)))
_a = np.array(body)[..., 3]
_hole = cv2.dilate((_a > 10).astype(np.uint8), np.ones((5, 5), np.uint8), 2)
plate = cv2.cvtColor(cv2.inpaint(cv2.cvtColor(bg, cv2.COLOR_RGB2BGR), _hole, 9, cv2.INPAINT_TELEA), cv2.COLOR_BGR2RGB)
plate = np.where(_hole[..., None] > 0, plate, bg).astype(np.float32)
_ys, _xs = np.where(_a > 10)
PIVOT = (float(_xs.mean()), float(_ys.max()))   # 足もと
# 跳ぶとき用: 足もとの影まで消した床と、高さで変わる影
_band = np.zeros_like(_hole)
_x0, _x1, _yb = int(_xs.min()) - 40, int(_xs.max()) + 40, int(_ys.max())
_g = bg.astype(int).mean(2)
_ref = np.median(_g[_yb + 40:_yb + 90, _x0:_x1])
_band[_yb - 120:_yb + 45, _x0:_x1] = (_g[_yb - 120:_yb + 45, _x0:_x1] < _ref - 12)
_mask2 = cv2.dilate(np.maximum(_hole, _band), np.ones((9, 9), np.uint8), 2)
plate_clean = cv2.cvtColor(cv2.inpaint(cv2.cvtColor(bg, cv2.COLOR_RGB2BGR), _mask2, 25, cv2.INPAINT_TELEA), cv2.COLOR_BGR2RGB)
plate_clean = cv2.GaussianBlur(plate_clean, (0, 0), 3) * (_mask2[..., None] > 0) + bg * (_mask2[..., None] == 0)
plate_clean = plate_clean.astype(np.float32)
SHADOW_W = (_xs.max() - _xs.min()) * 0.62

_e = os.path.join(hk.ASSET_DIR, "bg_empty.png")   # ロボットを消した病室（LaMa で埋めた）
plate_empty = np.array(Image.open(_e).convert("RGB")).astype(np.float32) if os.path.exists(_e) else plate_clean

def shadow(height, x=0.0, widen=1.0, y=0.0):
    """足もとの影（跳ぶほど小さく薄く）。返り値: 暗くする量 0〜1 の画像"""
    k = 1 / (1 + max(height, 0) / 60)
    m = np.zeros((Hh, Ww), np.float32)
    cv2.ellipse(m, (int(PIVOT[0] - 8 + x), int(PIVOT[1] - 6 + y)), (int(SHADOW_W * k * widen), int(20 * k)), 0, 0, 360, 1.0, -1, cv2.LINE_AA)
    return cv2.GaussianBlur(m, (0, 0), 9) * 0.55 * k
fx0, fy0 = bot.fbox[0], bot.fbox[1]
_cache = {}

_pk = {}
def _pumpkin(w, h):
    if (w, h) not in _pk:
        from pumpkin import pumpkin
        _pk[(w, h)] = pumpkin(w, h)
    return _pk[(w, h)]

TINT = [(1.0, 1.0, 1.0)]
HAT = [None]          # 頭にかぶせる物（"pumpkin"）。パターンの hat で決める

def robot_rgba(eyes, brows, mouth, ring):
    k = (eyes, brows, mouth, round(ring, 2), TINT[0], HAT[0])
    if k not in _cache:
        b = bot.body.copy()                           # 体の座標で描いてから、to_canvas で画面へ
        if TINT[0] != (1.0, 1.0, 1.0):                # 背景の明るさ・色に合わせて体を染める（顔とリングは光るので染めない）
            ba = np.array(b).astype(np.float32); ba[..., :3] *= np.array(TINT[0], np.float32)
            b = Image.fromarray(np.clip(ba, 0, 255).astype(np.uint8))
        bot._ring(b, ring)
        if eyes[0] == "orange" or HAT[0] == "pumpkin":  # かぼちゃを頭にかぶった（画面がふさがって全面オレンジ）
            sw = _sxs.max() - _sxs.min(); pk = _pumpkin(int(2.33 * sw), int(2.33 * sw * 300 / 420))
            px = (_sxs.min() + _sxs.max()) / 2 + 0.27 * sw - pk.width / 2
            py = _sys.max() + 0.29 * sw - pk.height
            b.alpha_composite(pk, (int(px), int(py)))
        else:
            gl, la = bot._face_layers(eyes, brows, mouth, 0.0)
            b.alpha_composite(gl, (fx0, fy0)); b.alpha_composite(la, (fx0, fy0))
        arr = np.array(b).astype(np.float32)
        arr = to_canvas(arr)
        arr[..., :3] *= arr[..., 3:4] / 255
        _cache[k] = arr
        if len(_cache) > 400:
            _cache.pop(next(iter(_cache)))
    return _cache[k]

LEFT, RIGHT = float(_xs.min()), float(_xs.max())   # 体の左端・右端（元の位置）

def _rs(rot, sx, sy, about):
    c, s = np.cos(np.radians(rot)), np.sin(np.radians(rot))
    A = np.array([[c * sx, s * sy], [-s * sx, c * sy]])
    p = np.array(about)
    M = np.eye(3); M[:2, :2] = A; M[:2, 2] = p - A @ p
    return M

def place(arr, dx=0, dy=0, rot=0, sx=1, sy=1, flip=False, fall=0, fall_side=-1, s=1.0, **_):
    """足もとを中心に、(左右反転して) 回して・伸び縮みさせて・ずらす。
    fall: 倒れる角度。fall_side=-1 なら体の左下の角、+1 なら右下の角を中心に倒れる"""
    M = _rs(rot, s * sx * (-1 if flip else 1), s * sy, PIVOT)
    T = np.eye(3); T[:2, 2] = (dx, dy)
    M = T @ M
    if fall:
        half = (RIGHT - LEFT) / 2
        corner = (PIVOT[0] + dx + fall_side * half * 0.92 * s, PIVOT[1] + dy)
        M = _rs(-fall_side * fall, 1, 1, corner) @ M
    return cv2.warpAffine(arr, M[:2].astype(np.float32), (Ww, Hh), flags=cv2.INTER_LINEAR, borderValue=(0, 0, 0, 0))

def ease(u):
    u = np.clip(u, 0, 1); return u * u * (3 - 2 * u)

# ================= パターン =================
# 各パターン: dur, motion(t)->dict, face(t)->(eyes, brows, mouth), ring(t), blur(t), dim(t)
class Panic:            # 大あわて
    dur = 5.0
    A, P, S = 0.12, 0.35, 3.9
    def motion(self, t):
        if t < self.A: return {}
        if t < self.S:
            r = min(1, (t - self.A) / 0.25)
            drift = 22 * np.sin(2 * np.pi * 0.9 * (t - self.A))
            return dict(dx=r * (9 * np.sin(2 * np.pi * 7.3 * t) + 4 * np.sin(2 * np.pi * 11.1 * t + 1)) - drift * 0.6,
                        dy=-r * abs(np.sin(2 * np.pi * 3.6 * t)) * 7 + drift * 0.25,
                        rot=r * (2.4 * np.sin(2 * np.pi * 7.3 * t + 0.6) + 0.8 * np.sin(2 * np.pi * 13 * t)),
                        sx=1 + 0.035 * np.sin(2 * np.pi * 0.9 * (t - self.A)), sy=1 + 0.035 * np.sin(2 * np.pi * 0.9 * (t - self.A)))
        u = t - self.S; k = np.exp(-u * 18) * np.cos(2 * np.pi * 6 * u)
        return dict(dx=6 * k, rot=1.5 * k)
    def face(self, t):
        if t < self.A: return ("open", "open"), None, "mid"
        if t < self.P: return ("big", "big"), None, "o"
        if t < self.S: return ("chev", "chev"), None, ("wave" if int((t - self.P) * 9) % 2 == 0 else "wave2")
        return (("blink", "blink") if 4.55 < t < 4.65 else ("dot", "dot")), None, "flat"
    def ring(self, t):
        if self.A <= t < self.S: return 1.0 if int(t * 12) % 2 == 0 else 0.15
        if t >= self.S: return 0.15 + 0.25 * min(1, (t - self.S) / 0.8)
        return 0.4
    def blur(self, t): return self.A <= t < self.S + 0.1
    def dim(self, t): return 1.0

class Sad:              # 落ちこむ: しょんぼり沈んで、前に傾き、涙がひとつぶ。部屋が少し暗くなる
    dur = 5.0
    def motion(self, t):
        u = ease((t - 0.5) / 1.6)
        sigh = 0.006 * np.sin(2 * np.pi * 0.45 * t) * u          # ゆっくりした息
        return dict(rot=-4.5 * u, sy=1 - 0.045 * u + sigh, sx=1 + 0.012 * u, dx=-6 * u)
    def face(self, t):
        if t < 0.35: return ("open", "open"), None, "mid"
        if t < 1.3: return ("open", "open"), "trouble", "frown"
        if t < 2.2: return ("down", "down"), "trouble", "frown"
        k = int(min(1, (t - 2.2) / 1.6) ** 1.6 * 40)            # 涙がゆっくり流れる
        if t > 4.1: return ("down", "down"), "trouble", "frown"
        return ("down", "down"), "trouble", f"frown_tear{k}"
    def ring(self, t): return 0.4 * (1 - ease((t - 0.5) / 2.0)) + 0.02
    def blur(self, t): return False
    def dim(self, t): return 1 - 0.18 * ease((t - 0.6) / 2.2)

class Freeze:           # 固まる（仕事で失敗）: 調子よく進む → ガン！とぶつかる → 画面がチラつく → ×の目で傾いたまま固まる
    dur = 5.0
    HIT = 0.9
    def motion(self, t):
        if t < self.HIT:
            u = t / self.HIT
            return dict(dx=-14 * u, dy=4 * u, sx=1 + 0.02 * u, sy=1 + 0.02 * u,
                        rot=0.6 * np.sin(2 * np.pi * 4 * t))        # るんるん進む
        u = t - self.HIT
        bounce = 16 * np.exp(-u * 14) * np.cos(2 * np.pi * 7 * u)  # ぶつかって跳ね返る
        lean = 7.5 * ease((u - 0.9) / 0.5)                            # そのまま、ゆっくり後ろへ傾く
        g = 0
        if 0.1 < u < 0.9 and int(u * 30) % 4 == 0: g = 10 * (1 if int(u * 60) % 2 else -1)   # チラつき
        return dict(dx=-14 + bounce + g, dy=4, sx=1.02, sy=1.02 - 0.03 * np.exp(-u * 10), rot=lean + 3 * np.exp(-u * 12) * np.sin(2 * np.pi * 9 * u))
    def face(self, t):
        if t < self.HIT: return ("happy", "happy"), None, "closed"
        u = t - self.HIT
        if u < 0.9:
            fl = int(u * 30) % 5
            return [(("big", "big"), None, "o"), (("none", "none"), None, "none"), (("x", "x"), None, "o"),
                    (("none", "none"), None, "none"), (("big", "big"), None, "o")][fl]
        return ("x", "x"), None, "o"
    def ring(self, t):
        if t < self.HIT: return 0.6
        u = t - self.HIT
        if u < 0.9: return 1.0 if int(u * 20) % 2 else 0.0
        return 0.0 if int(u * 2.2) % 2 else 0.2                    # ゆっくり点いたり消えたり
    def blur(self, t): return t >= self.HIT and t < self.HIT + 0.5
    def dim(self, t): return 1.0

class Joy:              # 大喜び: ぴょんぴょん跳ねる（縮んで→伸びて→着地）
    dur = 5.0
    clean = True
    JUMP = 70                                        # 跳ぶ高さ（px）
    def motion(self, t):
        if t < 0.15: return {}
        if t > 4.2:
            u = t - 4.2; k = np.exp(-u * 10)
            return dict(sy=1 - 0.05 * k * np.cos(2 * np.pi * 4 * u), sx=1 + 0.03 * k * np.cos(2 * np.pi * 4 * u))
        T = 0.58                                     # 1回の跳ねる長さ
        u = ((t - 0.15) % T) / T
        n = int((t - 0.15) / T)
        side = 1 if n % 2 else -1
        if u < 0.18:                                 # しゃがむ
            v = np.sin(np.pi * u / 0.18)
            return dict(sy=1 - 0.08 * v, sx=1 + 0.05 * v)
        v = (u - 0.18) / 0.82                        # 跳ぶ
        h = 4 * v * (1 - v)
        return dict(dy=-self.JUMP * h, sy=1 + 0.05 * h, sx=1 - 0.03 * h, rot=side * 3.5 * np.sin(np.pi * v), dx=side * 8 * np.sin(np.pi * v))
    def face(self, t):
        if t < 0.15: return ("open", "open"), None, "mid"
        if t > 4.3: return ("happy", "happy"), None, "closed"
        return ("chev", "chev"), None, ("tri" if int(t * 6) % 2 == 0 else "large")
    def ring(self, t): return 0.5 + 0.5 * (0.5 + 0.5 * np.sin(2 * np.pi * 3 * t)) if t < 4.3 else 0.6
    def blur(self, t): return 0.15 < t < 4.4
    def dim(self, t): return 1.0

class Comfort:          # なぐさめる: ゆっくり近づいて、2回うなずき、目がハートに
    dur = 5.0
    def motion(self, t):
        u = ease(t / 1.6)                                   # 手前に近づく
        nod = 0
        for c in (2.0, 2.9):                                # うなずき2回
            v = (t - c) / 0.6
            if 0 < v < 1: nod += np.sin(np.pi * v)
        sway = 1.2 * np.sin(2 * np.pi * 0.5 * t)            # ゆらゆら
        return dict(dx=-30 * u, dy=14 * u + 6 * nod, sx=1 + 0.06 * u, sy=1 + 0.06 * u - 0.035 * nod, rot=sway - 2.5 * nod)
    def face(self, t):
        if t < 0.4: return ("open", "open"), None, "mid"
        if t < 3.6: return ("happy", "happy"), None, "closed"
        return ("heart", "heart"), None, "closed"
    def ring(self, t): return 0.35 + 0.35 * (0.5 + 0.5 * np.sin(2 * np.pi * 0.6 * t))
    def blur(self, t): return False
    def dim(self, t): return 1.0

def _shake(t, t0, amp, decay=9.0, hz=23.0, ph=0.0):
    if t < t0: return 0.0
    v = t - t0
    return amp * np.exp(-v * decay) * np.sin(2 * np.pi * hz * v + ph)

class Rampage:          # 暴走してこける: 右へ突進→壁にゴン→左へ突進→壁にガン！→ぐらぐら→あおむけに倒れる
    dur = 6.2
    empty = True
    sub = 4
    S = 0.88                                   # 走りながら少し奥へ下がる（倒れても画面に収まる大きさ）
    T_R0, T_R1 = 0.25, 0.95                   # 右へ突進
    T_FLIP = 1.3
    T_L0, T_L1 = 1.45, 2.05                    # 左へ突進
    T_F0, T_F1 = 2.5, 2.85                     # 倒れる
    FALL_SHIFT = 0                             # 倒れながら横へずれる量（倒れた体が画面に収まるように）
    def __init__(self):
        S = self.S
        self.dxR = Ww - (PIVOT[0] + (PIVOT[0] - LEFT) * S) - 4      # 反転すると右端は元の左端の鏡
        self.dxL = -(PIVOT[0] - (PIVOT[0] - LEFT) * S) + 2
    def scale(self, t):
        u = ease((t - self.T_R0) / (self.T_R1 - self.T_R0))
        sc = 1 - (1 - self.S) * u
        return sc, -(1 - sc) * 300
    def motion(self, t):
        sc, dep = self.scale(t)
        if t < self.T_R0:                      # 身構える
            v = np.sin(np.pi * t / self.T_R0)
            return dict(sy=1 - 0.08 * v, sx=1 + 0.05 * v, flip=t > 0.12)
        if t < self.T_R1:                      # 右へ突進（だんだん速く）
            u = (t - self.T_R0) / (self.T_R1 - self.T_R0)
            return dict(dx=self.dxR * u * u, dy=dep - abs(np.sin(2 * np.pi * 8 * t)) * 8, rot=-8 * min(1, u * 3),
                        sx=1.07, sy=0.95, flip=True, s=sc)
        if t < self.T_L0:                      # 右の壁にゴン → はね返る → 向きを変える
            v = t - self.T_R1
            back = 45 * ease(v / 0.3)
            squash = np.exp(-v * 14)
            flip = t < self.T_FLIP
            ant = np.sin(np.pi * np.clip((t - self.T_FLIP) / (self.T_L0 - self.T_FLIP), 0, 1))
            return dict(dx=self.dxR - back, dy=dep, rot=4 * squash * np.cos(2 * np.pi * 6 * v),
                        sx=1 - 0.16 * squash + 0.05 * ant, sy=1 + 0.06 * squash - 0.08 * ant, flip=flip, s=sc)
        if t < self.T_L1:                      # 左へ突進（さっきより速い）
            u = (t - self.T_L0) / (self.T_L1 - self.T_L0)
            x0 = self.dxR - 45
            return dict(dx=x0 + (self.dxL - x0) * u * u, dy=dep - abs(np.sin(2 * np.pi * 9 * t)) * 10,
                        rot=9 * min(1, u * 3), sx=1.1, sy=0.94, s=sc)
        if t < self.T_F0:                      # 左の壁にガン！ → ぐらぐら
            v = t - self.T_L1
            squash = np.exp(-v * 12)
            tee = 7 * np.sin(2 * np.pi * 2.6 * v) * min(1, v / 0.25) - 5 * ease(v / (self.T_F0 - self.T_L1))
            return dict(dx=self.dxL + 6 * ease(v / 0.2), dy=dep, rot=tee, sx=1 - 0.2 * squash, sy=1 + 0.08 * squash, s=sc)
        x = self.dxL + 6
        if t < self.T_F1:                      # あおむけに倒れる（右下の角を中心に）
            u = (t - self.T_F0) / (self.T_F1 - self.T_F0)
            return dict(dx=x + self.FALL_SHIFT * ease((u - 0.35) / 0.65), dy=dep, fall=5 + 85 * u * u, fall_side=1, s=sc)
        w = t - self.T_F1                      # ドスン → 小さくはねて止まる
        f = 90 - 9 * np.exp(-w * 8) * abs(np.sin(2 * np.pi * 2.2 * w))
        if 4.55 < t < 4.75: f += 2.5 * np.sin(2 * np.pi * 12 * (t - 4.55))   # ピクッ
        return dict(dx=x + self.FALL_SHIFT, dy=dep, fall=f, fall_side=1, s=sc)
    def cam(self, t):
        x = _shake(t, self.T_R1, 9) + _shake(t, self.T_L1, 24, 7) + _shake(t, self.T_F1, 4, 8, 17, 1)
        y = _shake(t, self.T_F1, 16, 8, 19) + _shake(t, self.T_L1, 6, 9, 29, 1)
        return x, y
    def face(self, t):
        if t < self.T_R0: return ("big", "big"), None, "o"
        if self.T_R1 <= t < self.T_R1 + 0.22: return ("x", "x"), None, "o"
        if self.T_L1 <= t < self.T_L1 + 0.25: return ("x", "x"), None, "o"
        if self.T_L1 + 0.25 <= t < self.T_F0: return ("dot", "dot"), None, "wave"
        if self.T_F0 <= t < self.T_F1: return ("big", "big"), None, "o"
        if t >= self.T_F1: return ("x", "x"), None, ("wave" if int(t * 3) % 2 else "wave2")
        return ("chev", "chev"), None, ("wave" if int(t * 9) % 2 == 0 else "wave2")
    def ring(self, t):
        if t < self.T_F1: return 1.0 if int(t * 12) % 2 == 0 else 0.15
        if 4.55 < t < 4.75: return 1.0
        return 0.35 if int(t * 1.5) % 2 else 0.05
    def blur(self, t): return t < self.T_F1 + 0.3
    def dim(self, t): return 1.0

class PanicBig:         # 大あわて（大げさ版）: 左右に走り回り、跳ねて、ぴたっと止まる
    dur = 5.0
    empty = True
    sub = 4
    A, P, S = 0.1, 0.3, 3.95
    AMP = 165                                  # 左右に走る幅（px）
    def x(self, t):
        u = t - self.P
        return self.AMP * np.sin(2 * np.pi * 0.62 * u) * min(1, u / 0.3)
    def motion(self, t):
        if t < self.A: return {}
        if t < self.P:
            v = np.sin(np.pi * (t - self.A) / (self.P - self.A))
            return dict(sy=1 - 0.1 * v, sx=1 + 0.06 * v, dy=-25 * v)
        if t < self.S:
            x = self.x(t); vel = (self.x(t + 0.01) - x) / 0.01
            return dict(dx=x + 14 * np.sin(2 * np.pi * 7.3 * t),
                        dy=-abs(np.sin(2 * np.pi * 3.4 * t)) * 38,
                        rot=-vel * 0.025 + 5 * np.sin(2 * np.pi * 7.3 * t + 0.6),
                        sx=1 + 0.06 * abs(np.sin(2 * np.pi * 3.4 * t)), sy=1 - 0.05 * abs(np.sin(2 * np.pi * 3.4 * t + 1.2)),
                        flip=vel > 0)
        x = self.x(self.S); u = t - self.S; k = np.exp(-u * 16) * np.cos(2 * np.pi * 6 * u)
        return dict(dx=x + 14 * k, rot=4 * k, sy=1 - 0.08 * np.exp(-u * 12))
    def cam(self, t):
        if self.P <= t < self.S: return 3 * np.sin(2 * np.pi * 19 * t), 3 * np.sin(2 * np.pi * 23 * t)
        return _shake(t, self.S, 7, 10), 0.0
    def face(self, t):
        if t < self.A: return ("open", "open"), None, "mid"
        if t < self.P + 0.1: return ("big", "big"), None, "o"
        if t < self.S: return ("chev", "chev"), None, ("wave" if int(t * 10) % 2 == 0 else "wave2")
        return (("blink", "blink") if 4.55 < t < 4.65 else ("dot", "dot")), None, "flat"
    def ring(self, t):
        if self.A <= t < self.S: return 1.0 if int(t * 14) % 2 == 0 else 0.1
        return 0.15 + 0.25 * min(1, max(0, t - self.S) / 0.8)
    def blur(self, t): return self.A <= t < self.S + 0.15
    def dim(self, t): return 1.0

class Summon:           # 呼ばれた！: ピコン→その場で空回り→カメラの方へ突進→キキーッと止まって「オ呼ビデスカ？」
    dur = 5.1
    empty = True
    sub = 4
    A, V0, R0, R1, K = 1.0, 2.0, 2.55, 3.0, 3.4   # 「運ぶ」で反応（1.0秒）→ 言い終わるのを待って「ハイッ」→ 空回り（2.0）→ 突進
    S1 = 1.33
    talk = (3.55, 4.23)            # 「オ呼ビデスカ？」を言う間（声を取り込んだら合わせる）
    def motion(self, t):
        if t < self.A:
            tw = 7 * np.sin(np.pi * (t - 0.35) / 0.2) if 0.35 <= t < 0.55 else 0   # 「箱」でピクッ
            return dict(sh_y=0, dy=-tw, sh_h=tw, sy=1 + 0.006 * np.sin(2 * np.pi * 1.2 * t))
        if t < self.R0:
            v = t - self.A
            hop = 22 * np.sin(np.pi * min(v / 0.25, 1)) if v < 0.25 else 0
            crouch = ease((t - self.V0) / 0.2) if t >= self.V0 else 0
            vib = 5 * np.sin(2 * np.pi * 24 * t) * crouch
            return dict(dx=vib, dy=-hop, sy=1 - 0.05 * crouch + (0.04 if v < 0.12 else 0), sx=1 + 0.03 * crouch,
                        sh_y=0, sh_h=hop)
        if t < self.R1:
            u = (t - self.R0) / (self.R1 - self.R0)
            k = u * u
            sc = 1 + (self.S1 - 1) * k
            return dict(dx=-20 * k, dy=110 * k, s=sc, sy=1.05, sx=0.97, sh_y=110 * k)
        v = t - self.R1
        over = 0.05 * np.exp(-v * 9) * np.cos(2 * np.pi * 3 * v)
        rot = 4.5 * np.exp(-v * 7) * np.sin(2 * np.pi * 4 * v)
        bob = 4 * np.sin(2 * np.pi * 1.6 * (t - self.K)) if t > self.K else 0
        return dict(dx=-20, dy=110 - bob, s=self.S1 + over, rot=rot, sh_y=110)
    def cam(self, t):
        return _shake(t, self.R1, 10, 9, 21), _shake(t, self.R1, 6, 9, 27, 1)
    def face(self, t):
        if 0.35 <= t < 0.7: return ("big", "big"), None, "mid"     # 「箱」でピクッ
        if t < self.A: return ("open", "open"), None, "mid"
        if t < self.A + 0.25: return ("big", "big"), None, "o"
        if t < self.V0: return ("big", "big"), None, "mid"          # 言い終わるのを待つ
        if t < self.R1: return ("chev", "chev"), None, "large"
        a, b = self.talk
        if a <= t < b:
            return ("open", "open"), None, ("large" if int((t - a) * 9) % 2 == 0 else "small")
        return ("open", "open"), None, "mid"
    def ring(self, t):
        if self.A <= t < self.A + 0.3: return 1.0
        if self.A + 0.3 <= t < self.V0: return 0.8
        if self.V0 <= t < self.R1 + 0.2: return 1.0 if int(t * 14) % 2 == 0 else 0.3
        return 0.55 + 0.2 * np.sin(2 * np.pi * 1.2 * t)
    def blur(self, t): return (self.A <= t < self.A + 0.25) or (self.V0 <= t < self.R1 + 0.25)
    def dim(self, t): return 1.0

class KensuJoy:         # 件数と聞いて大喜び: 話を聞いている → 「件数」で目がハート → ぴょんぴょん
    dur = 2.7
    empty = True
    sub = 3
    A = 1.55                       # 「件数」と聞こえる時刻（声を取り込んだら合わせる）
    def motion(self, t):
        if t < self.A:
            return dict(sh_y=0, sy=1 + 0.006 * np.sin(2 * np.pi * 1.2 * t))
        v = t - self.A
        T = 0.42
        u = (v % T) / T
        h = 4 * u * (1 - u)
        sq = np.exp(-((u - 0.0) / 0.12) ** 2) + np.exp(-((u - 1.0) / 0.12) ** 2)
        return dict(dy=-38 * h, sh_y=0, sh_h=38 * h, sy=1 + 0.05 * h - 0.06 * sq, sx=1 - 0.03 * h + 0.04 * sq)
    def face(self, t):
        if t < self.A: return ("open", "open"), None, "mid"
        return ("heart", "heart"), None, "tri"
    def ring(self, t):
        if t < self.A: return 0.4
        return 1.0 if int(t * 10) % 2 == 0 else 0.4
    def blur(self, t): return t >= self.A
    def dim(self, t): return 1.0

class Shuttle:          # 1錠ずつ全力で往復: 「1件目、配送シマス！」→ 左右に10往復 → 得意げに止まる
    dur = 5.3
    empty = True
    sub = 5
    talk = (0.12, 1.92)            # 「1件目、配送シマス！」を言う間
    T0, TRIP, N = 2.0, 0.28, 10   # 往復の始まり・1回の時間・回数
    L, R = -250.0, 170.0
    def pos(self, t):
        """(x, 進む向き>0なら右, 動いているか)"""
        if t < self.T0: return 0.0, 0, False
        k = int((t - self.T0) / self.TRIP)
        if k >= self.N:
            end = self.R if (self.N % 2 == 1) else self.L
            return end, 0, False
        u = (t - self.T0 - k * self.TRIP) / self.TRIP
        a = 0.0 if k == 0 else (self.R if k % 2 == 1 else self.L)
        b = self.R if k % 2 == 0 else self.L
        if k == 0: a = 0.0
        return a + (b - a) * ease(u), (1 if b > a else -1), True
    def motion(self, t):
        x, d, moving = self.pos(t)
        if t < self.T0:
            return dict(sh_y=0, dy=-4 * abs(np.sin(2 * np.pi * 2 * t)), sh_h=0)
        if moving:
            return dict(dx=x, flip=d > 0, sx=1.08, sy=0.95, dy=-10 * abs(np.sin(np.pi * ((t - self.T0) % self.TRIP) / self.TRIP)),
                        rot=-3 * d, sh_y=0)
        v = t - (self.T0 + self.N * self.TRIP)
        k = np.exp(-v * 10) * np.cos(2 * np.pi * 4 * v)
        return dict(dx=x, sx=1 + 0.06 * k, sy=1 - 0.06 * k, rot=3 * k, sh_y=0)
    def face(self, t):
        a, b = self.talk
        if a <= t < b: return ("chev", "chev"), None, ("large" if int((t - a) * 9) % 2 == 0 else "small")
        if t < self.T0: return ("chev", "chev"), None, "tri"
        if t < self.T0 + self.N * self.TRIP: return ("chev", "chev"), None, "large"
        return ("happy", "happy"), None, "closed"
    def ring(self, t):
        if t < self.T0: return 0.6
        return 1.0 if int(t * 14) % 2 == 0 else 0.3
    def blur(self, t): return self.T0 <= t < self.T0 + self.N * self.TRIP + 0.1
    def dim(self, t): return 1.0

class Blind:            # 第81話: かぼちゃで画面が全面オレンジ → 前が見えずにフラフラ、ぐるぐる
    dur = 5.0
    empty = True
    sub = 4
    A = 0.25
    def motion(self, t):
        if t < self.A: return dict(sh_y=0)
        v = t - self.A
        dx = 120 * np.sin(2 * np.pi * 0.37 * v) + 35 * np.sin(2 * np.pi * 1.3 * v + 1)
        rot = 7 * np.sin(2 * np.pi * 1.1 * v) + 3 * np.sin(2 * np.pi * 3.3 * v)
        dy = -abs(np.sin(2 * np.pi * 1.6 * v)) * 10
        vel = 120 * 2 * np.pi * 0.37 * np.cos(2 * np.pi * 0.37 * v)
        return dict(dx=dx, dy=dy, rot=rot, flip=vel > 0, sh_y=0)
    def face(self, t): return ("orange", "orange"), None, "mid"
    def ring(self, t): return 0.6 + 0.4 * np.sin(2 * np.pi * 2 * t)
    def blur(self, t): return t >= self.A
    def dim(self, t): return 1.0

class Reverse:          # 第85話: 「取り消し！」→ ピーピーとバックで遠ざかる
    dur = 4.6
    empty = True
    sub = 3
    A, B0, B1 = 0.9, 1.3, 3.6       # 「取り消し」で反応・バック開始・止まる
    def motion(self, t):
        if t < self.A: return dict(sh_y=0, dx=-30 * ease(t / 0.6), s=1.0)
        if t < self.B0:
            v = (t - self.A) / (self.B0 - self.A)
            return dict(dx=-30, sh_y=0, sy=1 + 0.04 * np.sin(np.pi * v), dy=-10 * np.sin(np.pi * v))
        u = ease((t - self.B0) / (self.B1 - self.B0))
        sc = 1 - 0.32 * u
        jig = 3 * np.sin(2 * np.pi * 9 * t) * (1 - abs(2 * u - 1))
        return dict(dx=-30 + 140 * u + jig, dy=-170 * u, s=sc, sh_y=-170 * u)
    def face(self, t):
        if t < self.A: return ("open", "open"), None, "mid"
        if t < self.B0: return ("big", "big"), None, "o"
        if t < self.B1: return ("open", "open"), None, ("small" if int(t * 6) % 2 else "mid")
        return ("happy", "happy"), None, "closed"
    def ring(self, t):
        if self.B0 <= t < self.B1: return 1.0 if int(t * 4) % 2 == 0 else 0.1    # ピー、ピー
        return 0.4
    def blur(self, t): return self.B0 <= t < self.B1
    def dim(self, t): return 1.0

class Sigh:             # 第82話: ため息をまねる → どんどん長いため息
    dur = 5.2
    empty = False
    S1, S2 = (0.6, 1.8), (2.2, 4.8)   # 1回目・2回目（長い）のため息
    def _one(self, t, a, b):
        if not (a <= t < b): return 0.0, 0.0
        u = (t - a) / (b - a)
        inhale = np.sin(np.pi * min(u / 0.3, 1)) if u < 0.3 else 0
        exhale = ease((u - 0.25) / 0.75)
        return inhale, exhale
    def motion(self, t):
        i1, e1 = self._one(t, *self.S1); i2, e2 = self._one(t, *self.S2)
        slump = max(e1 * 0.5, e2 * 1.0, 0.5 if t >= self.S1[1] else 0)
        if t >= self.S2[1]: slump = 1.0
        return dict(sy=1 + 0.03 * max(i1, i2) - 0.06 * slump, sx=1 + 0.02 * slump, rot=-3 * slump,
                    dy=-8 * max(i1, i2))
    def face(self, t):
        if t < self.S1[0]: return ("open", "open"), None, "mid"
        if t < self.S2[0]: return ("half", "half"), "trouble", "frown"
        return ("down", "down"), "trouble", ("o" if self.S2[0] + 0.5 < t < self.S2[1] - 0.3 else "frown")
    def ring(self, t): return 0.45 * (1 - ease((t - 0.6) / 4.0)) + 0.03
    def blur(self, t): return False
    def dim(self, t): return 1 - 0.12 * ease((t - 2.2) / 2.5)

class Petted:           # 第20話: 撫でられてにこにこ → 画面に「撫デテ下サイ（強制）」と出して動かない
    dur = 5.0
    empty = False
    TX = 2.3                          # 文字が出る時刻
    def motion(self, t):
        if t < self.TX:
            return dict(sy=1 - 0.025 * (0.5 + 0.5 * np.sin(2 * np.pi * 1.6 * t)), rot=1.5 * np.sin(2 * np.pi * 0.8 * t))
        v = t - self.TX
        return dict(sy=1 + 0.03 * np.exp(-v * 10) * np.cos(2 * np.pi * 5 * v))
    def face(self, t):
        if t < 0.3: return ("open", "open"), None, "mid"
        if t < self.TX: return ("happy", "happy"), None, "closed"
        return ("text:撫デテ下サイ|（強制）", "text:撫デテ下サイ|（強制）"), None, "none"
    def ring(self, t):
        if t < self.TX: return 0.5 + 0.3 * np.sin(2 * np.pi * 1.6 * t)
        return 1.0 if int((t - self.TX) * 3) % 2 == 0 else 0.4
    def blur(self, t): return False
    def dim(self, t): return 1.0

class Follow:           # 第88話: 「ついてきて」→ トコトコついてくる（手前へ）
    dur = 4.8
    empty = True
    sub = 2
    A, STEP, N = 1.2, 0.36, 9
    def motion(self, t):
        if t < self.A: return dict(sh_y=0)
        k = min(int((t - self.A) / self.STEP), self.N)
        u = 0 if k >= self.N else ((t - self.A) % self.STEP) / self.STEP
        prog = (k + ease(u)) / self.N
        sc = 1 + 0.12 * prog                 # 頭も足も、ショートの映像の窓（y172〜937）に収まる大きさまで
        hop = 10 * np.sin(np.pi * u) if k < self.N else 0
        sway = 2.5 * np.sin(np.pi * (k + u)) if k < self.N else 0
        return dict(dx=-25 * prog, dy=22 * prog - hop, s=sc, rot=sway, sh_y=22 * prog, sh_h=hop)
    def face(self, t):
        if t < self.A: return ("open", "open"), None, "mid"
        return ("happy", "happy"), None, "closed"
    def ring(self, t): return 0.5 + 0.3 * np.sin(2 * np.pi * 1.4 * t)
    def blur(self, t): return False
    def dim(self, t): return 1.0

class Reboot:           # 第83話: 確認しすぎて固まる → 画面が横線 → 再起動「ハジメマシテ」
    dur = 5.0
    empty = False
    F0, L0, B0 = 1.4, 1.9, 3.0        # 固まる・横線・再起動
    def motion(self, t):
        if t < self.F0: return dict(sy=1 + 0.01 * np.sin(2 * np.pi * 3 * t))
        if t < self.L0:
            g = 8 * (1 if int(t * 40) % 2 else -1) if int(t * 15) % 3 == 0 else 0
            return dict(dx=g)
        if t < self.B0: return {}
        v = t - self.B0
        return dict(dy=-18 * np.exp(-v * 6) * abs(np.sin(2 * np.pi * 2 * v)))
    def face(self, t):
        if t < self.F0: return ("open", "open"), None, ("small" if int(t * 8) % 2 else "mid")
        if t < self.L0: return (("big", "big"), None, "o") if int(t * 20) % 3 else (("none", "none"), None, "none")
        if t < self.B0: return ("line", "line"), None, "none"
        if t < self.B0 + 0.4: return ("none", "none"), None, "none"
        if t < self.B0 + 0.55: return ("blink", "blink"), None, "closed"
        return ("open", "open"), None, "mid"
    def ring(self, t):
        if t < self.F0: return 0.5
        if t < self.L0: return 1.0 if int(t * 20) % 2 else 0.0
        if t < self.B0: return 0.0
        return min(1.0, (t - self.B0) * 1.5) * 0.6
    def blur(self, t): return self.F0 <= t < self.L0
    def dim(self, t): return 1.0

class Nod:              # 第87話: 「声は小さくね」にうなずく（2回）
    dur = 3.2
    empty = False
    NODS = (1.2, 1.75)
    def motion(self, t):
        nod = 0
        for c in self.NODS:
            v = (t - c) / 0.45
            if 0 < v < 1: nod += np.sin(np.pi * v)
        return dict(dy=7 * nod, sy=1 - 0.035 * nod, rot=-1.5 * nod)
    def face(self, t):
        if t < self.NODS[0] - 0.1: return ("open", "open"), None, "mid"
        return ("happy", "happy"), None, "closed"
    def ring(self, t): return 0.4
    def blur(self, t): return False
    def dim(self, t): return 1.0

class Bow:              # 第43話: 何度もお辞儀（謝りすぎ）
    dur = 5.0
    empty = False
    T0, P = 0.5, 0.55
    def motion(self, t):
        if t < self.T0: return {}
        u = ((t - self.T0) % self.P) / self.P
        b = np.sin(np.pi * u) ** 1.5
        return dict(dy=14 * b, sy=1 - 0.06 * b, rot=-2 * b)
    def face(self, t):
        if t < self.T0: return ("open", "open"), "trouble", "frown"
        return ("calm", "calm"), "trouble", "frown"
    def ring(self, t): return 0.25
    def blur(self, t): return False
    def dim(self, t): return 1.0

class Seq:              # 動きをつなぐ: parts = [(始まる秒, パターン, そのパターンの何秒目から)]
    def __init__(self, parts, dur):
        self.parts, self.dur = parts, dur
        self.empty = any(getattr(p, "empty", False) for _, p, _ in parts)
        self.sub = max(getattr(p, "sub", SUB) for _, p, _ in parts)
    def _at(self, t):
        t0, p, skip = [x for x in self.parts if x[0] <= t][-1] if t >= self.parts[0][0] else self.parts[0]
        return p, t - t0 + skip
    def motion(self, t): p, u = self._at(t); return p.motion(u)
    def face(self, t): p, u = self._at(t); return p.face(u)
    def ring(self, t): p, u = self._at(t); return p.ring(u)
    def blur(self, t): p, u = self._at(t); return p.blur(u)
    def dim(self, t): p, u = self._at(t); return p.dim(u)
    def cam(self, t):
        p, u = self._at(t)
        return p.cam(u) if hasattr(p, "cam") else (0.0, 0.0)

def _with(cls, **kw):
    o = cls()
    for k, v in kw.items(): setattr(o, k, v)
    return o

class PumpkinRampage(Rampage):   # 第81話: かぼちゃで前が見えず、走り回って壁にぶつかり、あおむけにこける
    hat = "pumpkin"
    dur = 6.4
    S = 0.8                                    # かぼちゃのぶん大きいので、少し小さく
    FALL_SHIFT = -200                          # かぼちゃのぶん長いので、左へずらして画面に収める
    PAD = 90                                   # かぼちゃが体より横に出るぶん、壁（画面の端）の手前で止まる
    def __init__(self):
        super().__init__()
        self.dxR -= self.PAD; self.dxL += self.PAD

def SighSad():                   # 第82話: ため息をまねる → 2回目で、しょんぼり沈んで涙
    return Seq([(0.0, _with(Sigh, S1=(1.25, 2.2), S2=(2.5, 4.6)), 0.0), (2.5, Sad(), 0.4)], 5.2)

def JoyPetted():                 # 第20話: 撫でられてぴょんぴょん喜ぶ → 画面に「撫デテ下サイ（強制）」
    return Seq([(0.0, _with(Joy, JUMP=45), 0.0), (2.4, _with(Petted, TX=0.0), 0.0)], 5.8)

def PanicBow():                  # 第43話: インシデントで大あわて → 何度もお辞儀
    return Seq([(0.0, _with(PanicBig, AMP=110), 0.0), (1.1, Bow(), 0.5)], 5.0)

PATTERNS = {"大あわて": Panic, "落ちこむ": Sad, "固まる": Freeze, "大喜び": Joy, "なぐさめる": Comfort,
            "暴走してこける": Rampage, "大あわて_大げさ": PanicBig, "呼ばれた": Summon, "件数で大喜び": KensuJoy, "往復": Shuttle,
            "かぼちゃ": Blind, "バック": Reverse, "ため息": Sigh, "撫でて": Petted, "ついてくる": Follow,
            "再起動": Reboot, "うなずく": Nod, "お辞儀": Bow,
            "かぼちゃで暴走": PumpkinRampage, "ため息で落ちこむ": SighSad, "喜んで撫でて": JoyPetted, "あわてて謝る": PanicBow}

# ================= 2026-10-20 追加の10話 =================
def T(*rows):                    # 画面に字を出す顔
    s = "text:" + "|".join(rows); return (s, s), None, "none"
def _talk(t, a, b, eyes=("open", "open"), brows=None):
    if a <= t < b: return eyes, brows, ("large" if int((t - a) * 9) % 2 == 0 else "small")
    return None
def _approach(t, t1=1.0, dist=230.0):   # 右から走ってきて止まる（最初の1秒を動きにする）
    if t >= t1 + 0.4:
        return {}
    if t >= t1:
        v = t - t1; k = np.exp(-v * 12) * np.cos(2 * np.pi * 5 * v)
        return dict(sx=1 + 0.05 * k, sy=1 - 0.05 * k, rot=2.5 * k)
    u = t / t1
    return dict(dx=dist * (1 - ease(u)), dy=-6 * abs(np.sin(2 * np.pi * 4 * t)), rot=-3 * (1 - u), sx=1.05, sy=0.97)
def _top():
    return float(_ys.min())

class Tidy:             # 第24話: 「障害物を検知」→「整理整頓 起動」→ 机の上を左右に全力で片付ける → 得意げ
    dur = 8.6
    empty = True
    sub = 5
    A, B, S0, TRIP, N = 3.2, 5.3, 7.15, 0.2, 6
    L, R = -230.0, 150.0
    def pos(self, t):
        if t < self.S0: return 0.0, 0, False
        k = int((t - self.S0) / self.TRIP)
        if k >= self.N: return (self.R if self.N % 2 == 1 else self.L), 0, False
        u = (t - self.S0 - k * self.TRIP) / self.TRIP
        a = 0.0 if k == 0 else (self.R if k % 2 == 1 else self.L)
        b = self.R if k % 2 == 0 else self.L
        return a + (b - a) * ease(u), (1 if b > a else -1), True
    def motion(self, t):
        x, d, mv = self.pos(t)
        if t < self.A:                               # 巡回: 右から入ってきて、左右を見る
            m = dict(_approach(t, 1.6, 300)); m["sh_y"] = 0
            if 2.0 < t < 2.9: m["flip"] = True
            return m
        if t < self.S0:
            v = t - self.B
            hop = 18 * np.sin(np.pi * min(v / 0.25, 1)) if 0 <= v < 0.25 else 0
            return dict(sh_y=0, dy=-hop, sh_h=hop, dx=4 * np.sin(2 * np.pi * 20 * t) if t >= self.B + 0.3 else 0)
        if mv:
            return dict(dx=x, flip=d > 0, sx=1.08, sy=0.95, rot=-4 * d, sh_y=0,
                        dy=-10 * abs(np.sin(np.pi * ((t - self.S0) % self.TRIP) / self.TRIP)))
        v = t - (self.S0 + self.N * self.TRIP); k = np.exp(-v * 10) * np.cos(2 * np.pi * 4 * v)
        return dict(dx=x, sx=1 + 0.06 * k, sy=1 - 0.06 * k, rot=3 * k, sh_y=0)
    def face(self, t):
        if t < self.A: return ("open", "open"), None, "mid"
        if t < self.B: return T("障害物", "検知") if int((t - self.A) * 4) % 2 == 0 else (("big", "big"), None, "o")
        if t < self.S0 + self.N * self.TRIP: return T("整理整頓", "起動")
        return ("happy", "happy"), None, "closed"
    def ring(self, t):
        if t < self.A: return 0.4
        return 1.0 if int(t * 12) % 2 == 0 else 0.2
    def blur(self, t): return self.S0 <= t < self.S0 + self.N * self.TRIP + 0.1
    def dim(self, t): return 1.0

class Spray:            # 第22話: 画面に「感染対策 強化月間」→ シュッ！シュッ！と消毒を吹きながら前へ
    dur = 5.6
    empty = True
    SP = (2.6, 3.2, 3.7, 4.1, 4.45, 4.75)   # 吹く時刻
    def _k(self, t):
        k = 0.0
        for s in self.SP:
            if s <= t < s + 0.3: k = max(k, np.exp(-(t - s) * 14))
        return k
    def motion(self, t):
        if t < 1.2: return dict(_approach(t, 1.0, 260), sh_y=0)
        u = ease((t - 2.5) / 2.6)
        k = self._k(t)
        return dict(dx=-30 * u + 10 * k, dy=40 * u, s=1 + 0.08 * u, sy=1 - 0.05 * k, sx=1 + 0.03 * k, rot=-2.5 * k, sh_y=40 * u)
    def face(self, t):
        if t < 2.5: return T("感染対策", "強化月間")
        return T("感染対策", "強化月間") if int(t * 6) % 2 == 0 else (("chev", "chev"), None, "tri")
    def ring(self, t): return 1.0 if self._k(t) > 0.3 else 0.5
    def blur(self, t): return False
    def dim(self, t): return 1.0
    def fx(self, t, frame, mo):        # 霧: 頭の横の吹き出し口から、前（左上）へ広がる水色の点
        rng = np.random.default_rng(7)
        s = mo.get("s", 1)
        nx = PIVOT[0] + mo.get("dx", 0) - (PIVOT[0] - LEFT) * s * 0.92
        ny = PIVOT[1] + mo.get("dy", 0) - (PIVOT[1] - _top()) * s * 0.7
        for sp in self.SP:
            v = t - sp
            if not (0 <= v < 0.75): continue
            n = 700
            ang = np.radians(rng.uniform(185, 228, n)); dist = rng.uniform(0.0, 1, n) ** 0.8 * (90 + 520 * min(v / 0.35, 1))
            xs = nx + np.cos(ang) * dist; ys = ny + np.sin(ang) * dist
            a = (1 - v / 0.75) * 0.95
            m = np.zeros((Hh, Ww), np.float32)
            for x, y, r in zip(xs, ys, rng.uniform(1.5, 5.0, n)):
                if 0 <= x < Ww and 0 <= y < Hh:
                    cv2.circle(m, (int(x), int(y)), int(r), 1.0, -1, cv2.LINE_AA)
            m = (cv2.GaussianBlur(m, (0, 0), 1.2) * a)[..., None]
            frame = frame * (1 - m) + np.array([110, 170, 230], np.float32) * m
        return frame

class Flee:             # 第86話: 手招きされるたびに後ずさり → 「なんで逃げるの！」→ ズギュン！と奥へ逃げる
    dur = 4.7
    empty = True
    sub = 4
    B = (1.25, 2.0)                # 手招きに合わせて1歩ずつ下がる
    Z0, Z1 = 3.25, 3.7             # ズギュン
    def motion(self, t):
        back = sum(ease((t - b) / 0.35) for b in self.B)
        sc = 1 - 0.06 * back; y = -45 * back; x = 25 * back
        if t < 0.9: return dict(_approach(t, 0.8, 200), sh_y=0)
        if t < self.Z0:
            tr = 3 * np.sin(2 * np.pi * 14 * t) if t > self.B[0] else 0
            return dict(dx=x + tr, dy=y, s=sc, sh_y=y)
        u = ease((t - self.Z0) / (self.Z1 - self.Z0))
        return dict(dx=x + 230 * u, dy=y - 190 * u, s=sc * (1 - 0.6 * u), rot=-6 * (1 - u), sh_y=y - 190 * u, flip=True)
    def face(self, t):
        if t < self.B[0]: return ("open", "open"), None, "mid"
        if t < self.B[0] + 0.25: return ("big", "big"), None, "o"
        if t < self.Z0: return ("dot", "dot"), None, "flat"
        return ("chev", "chev"), None, "o"
    def ring(self, t): return 1.0 if t >= self.Z0 and int(t * 14) % 2 == 0 else 0.4
    def blur(self, t): return self.Z0 <= t < self.Z1 + 0.1
    def dim(self, t): return 1.0

class FaceAuth:         # 第19話: 「顔認証を開始します」→ スキャン → ERROR！でガタガタ
    dur = 5.0
    empty = False
    S, E = 2.3, 4.0                # スキャンの始まり・エラー
    def motion(self, t):
        if t < 1.4: return _approach(t, 1.0, 230)
        if t < self.E: return dict(sy=1 + 0.005 * np.sin(2 * np.pi * 1.4 * t))
        v = t - self.E
        return dict(dx=9 * np.exp(-v * 3) * np.sin(2 * np.pi * 17 * v), rot=2.5 * np.exp(-v * 3) * np.sin(2 * np.pi * 11 * v))
    def cam(self, t): return _shake(t, self.E, 14, 6), _shake(t, self.E, 6, 7, 27, 1)
    def face(self, t):
        if t < self.S: return ("open", "open"), None, "mid"
        if t < self.E:
            d = "・" * (1 + int((t - self.S) * 3) % 3)
            return T("顔認証", "スキャン" + d)
        return T("ERROR") if int((t - self.E) * 5) % 2 == 0 else T("不正", "アクセス？")
    def ring(self, t):
        if t < self.S: return 0.4
        if t < self.E: return 0.5 + 0.5 * np.sin(2 * np.pi * 3 * t)
        return 1.0 if int(t * 10) % 2 == 0 else 0.0
    def blur(self, t): return False
    def dim(self, t): return 1.0

class Sleep:            # 第48話: 画面が真っ暗で動かない → ユサユサ揺さぶられる → 「省電力中デシタ」
    dur = 6.2
    empty = False
    Y0, Y1, W = 0.25, 2.4, 2.75    # 揺さぶる・起きる
    def motion(self, t):
        if self.Y0 <= t < self.Y1:
            r = min(1, (t - self.Y0) / 0.15)
            return dict(rot=r * 6 * np.sin(2 * np.pi * 3.2 * (t - self.Y0)), dx=r * 10 * np.sin(2 * np.pi * 3.2 * (t - self.Y0)))
        if t >= self.Y1:
            v = t - self.Y1
            return dict(rot=3 * np.exp(-v * 6) * np.sin(2 * np.pi * 3.2 * v))
        return {}
    def face(self, t):
        if t < self.W: return ("none", "none"), None, "none"
        if t < self.W + 0.25: return ("blink", "blink"), None, "closed"
        return T("省電力中", "デシタ")
    def ring(self, t): return 0.0 if t < self.W else min(1, (t - self.W) * 2) * 0.6
    def blur(self, t): return self.Y0 <= t < self.Y1
    def dim(self, t): return 1.0

class Slot:             # 第29話: 「いいなぁ…」→ 画面がスロットのようにクルクル → ジャラン！「ハワイ旅行」でぴょん
    dur = 5.0
    empty = False
    S0, S1 = 2.9, 4.4
    WORDS = ["温泉", "焼肉", "休日", "ケーキ", "昇給", "南の島", "温泉", "焼肉"]
    def motion(self, t):
        if t < self.S0: return _approach(t, 1.2, 260)
        if t < self.S1: return dict(dx=3 * np.sin(2 * np.pi * 16 * t), sy=1 + 0.01 * np.sin(2 * np.pi * 8 * t))
        v = t - self.S1
        h = 4 * (v / 0.45) * (1 - v / 0.45) if v < 0.45 else 0
        k = np.exp(-(v - 0.45) * 10) * np.cos(2 * np.pi * 4 * (v - 0.45)) if v >= 0.45 else 0
        return dict(dy=-60 * h, sy=1 + 0.05 * h - 0.05 * k, sx=1 + 0.03 * k)
    def face(self, t):
        if t < self.S0: return ("half", "half"), None, "mid"
        if t < self.S1:
            sp = 4 + 10 * (1 - (t - self.S0) / (self.S1 - self.S0))   # だんだん遅く
            return T(self.WORDS[int((t - self.S0) * sp) % len(self.WORDS)])
        return T("ハワイ", "旅行")
    def ring(self, t):
        if t < self.S0: return 0.3
        return 1.0 if int(t * 12) % 2 == 0 else 0.3
    def blur(self, t): return False
    def dim(self, t): return 1.0

class Torn:             # 第41話: 左右から同時に呼ばれ、あっち・こっちを向く → だんだん速く → 真ん中で「算出中…」
    dur = 6.3
    empty = False
    F = (0.35, 1.5, 2.1, 2.5, 2.8, 3.05, 3.25, 3.42, 3.56)   # 向きを変える時刻
    STOP = 3.72
    def motion(self, t):
        n = sum(1 for f in self.F if t >= f)
        if t < self.STOP:
            side = 1 if n % 2 == 1 else -1
            last = max([f for f in self.F if t >= f] or [0])
            k = np.exp(-(t - last) * 12)
            return dict(flip=(n % 2 == 1), dx=side * (14 + 10 * k), rot=-side * 3 * k, sy=1 - 0.03 * k)
        v = t - self.STOP; k = np.exp(-v * 9) * np.cos(2 * np.pi * 5 * v)
        return dict(rot=3 * k, sy=1 - 0.05 * np.exp(-v * 9))
    def face(self, t):
        if t < self.F[0]: return ("open", "open"), None, "mid"
        if t < self.STOP: return ("big", "big"), None, "o"
        d = "・" * (1 + int((t - self.STOP) * 3) % 3)
        return T("優先順位", "算出中" + d)
    def ring(self, t):
        if t >= self.STOP: return 0.5 + 0.5 * np.sin(2 * np.pi * 2 * t)
        return 0.6
    def blur(self, t): return self.F[3] <= t < self.STOP
    def dim(self, t): return 1.0

class Dither:           # 第67話: 曲がり角で清掃ロボと鉢合わせ → 同時に下がり、同時に出る（譲り合いのくり返し）
    dur = 6.4
    empty = True
    sub = 3
    CYC = ((0.7, 1.5), (1.9, 2.6), (2.95, 3.5), (3.8, 4.25), (4.5, 4.85))   # (前に出る, 下がる)
    def _x(self, t):
        x = -40.0                     # 下がった位置
        for a, b in self.CYC:
            if t >= a: x = -40 + 70 * ease((t - a) / 0.25)
            if t >= b: x = 30 - 70 * ease((t - b) / 0.25)
        return x
    def motion(self, t):
        x = self._x(t)
        stop = 5.1
        jit = 3 * np.sin(2 * np.pi * 10 * t) if t > stop else 0
        return dict(dx=x + jit - 110, flip=True, sh_y=0)
    def face(self, t):
        if t < 0.7: return ("open", "open"), None, "mid"
        for a, b in self.CYC:
            if a <= t < a + 0.35: return ("big", "big"), None, "o"
        if t > 5.1: return ("dot", "dot"), "trouble", "wave"
        return ("open", "open"), "trouble", "flat"
    def ring(self, t): return 0.5
    def blur(self, t): return False
    def dim(self, t): return 1.0
    def fx(self, t, frame, mo):        # 右から清掃ロボ（黒い箱）: ハコブくんと鏡うつしに出たり下がったり
        x = self._x(t)
        cx = int(Ww - 140 - x * 1.0)
        top, bot = int(PIVOT[1] - 400), int(PIVOT[1] - 4)
        w = 230
        cv2.ellipse(frame, (cx, bot + 2), (int(w * 0.55), 18), 0, 0, 360, (0, 0, 0), -1, cv2.LINE_AA)
        frame[:] = frame
        ov = frame.copy()
        cv2.rectangle(ov, (cx - w // 2, top + 40), (cx + w // 2, bot - 30), (52, 54, 58), -1)
        cv2.ellipse(ov, (cx, top + 40), (w // 2, 40), 0, 180, 360, (60, 62, 66), -1, cv2.LINE_AA)
        cv2.rectangle(ov, (cx - w // 2 + 8, top + 40), (cx + w // 2 - 8, top + 52), (70, 72, 76), -1)
        cv2.rectangle(ov, (cx - 16, top - 30), (cx + 16, top + 8), (40, 42, 45), -1)
        cv2.rectangle(ov, (cx - 80, top + 95), (cx + 20, top + 145), (25, 25, 28), -1)
        cv2.circle(ov, (cx - 30, top + 120), 17, (140, 150, 160), -1, cv2.LINE_AA)
        cv2.circle(ov, (cx - 30, top + 120), 9, (20, 20, 22), -1, cv2.LINE_AA)
        cv2.rectangle(ov, (cx - w // 2, bot - 40), (cx + w // 2, bot - 18), (30, 30, 33), -1)
        for wx in (cx - w // 2 + 50, cx + w // 2 - 50):
            cv2.circle(ov, (wx, bot - 14), 22, (18, 18, 20), -1, cv2.LINE_AA)
        if t > 5.1:                                   # 汗
            for i, (ox, oy) in enumerate(((-w // 2 - 20, top + 40), (-w // 2 - 40, top + 80))):
                cv2.ellipse(ov, (cx + ox, oy), (6, 11), 0, 0, 360, (120, 190, 255), -1, cv2.LINE_AA)
        return ov

class GhostBow:         # 第21話: 誰もいない壁（左）に向かって、にこにこお辞儀
    dur = 6.9
    empty = False
    T0, P = 0.25, 0.95
    def motion(self, t):
        if t < self.T0: return {}
        u = ((t - self.T0) % self.P) / self.P
        b = np.sin(np.pi * u) ** 1.5
        return dict(dy=10 * b, sy=1 - 0.05 * b, rot=4 * b)
    def face(self, t):
        if t < self.T0: return ("open", "open"), None, "mid"
        return ("happy", "happy"), None, "closed"
    def ring(self, t): return 0.35 + 0.15 * np.sin(2 * np.pi * 0.8 * t)
    def blur(self, t): return False
    def dim(self, t): return 1.0

class Transform:        # 第18話: 「最短ルート 算出中」→「変形モード？」でガタガタ・プシュー → フリーズ（×で傾く）
    dur = 7.3
    empty = False
    C0, V0, F0 = 2.9, 4.5, 6.6
    def motion(self, t):
        if t < 1.4: return _approach(t, 1.1, 250)
        if t < self.V0: return dict(sy=1 + 0.006 * np.sin(2 * np.pi * 1.2 * t))
        if t < self.F0:
            r = min(1, (t - self.V0) / 0.4)
            return dict(dx=r * 8 * np.sin(2 * np.pi * 19 * t), dy=-r * abs(np.sin(2 * np.pi * 6 * t)) * 10,
                        sy=1 + r * 0.05 * np.sin(2 * np.pi * 4 * t), sx=1 - r * 0.03 * np.sin(2 * np.pi * 4 * t))
        v = t - self.F0
        return dict(rot=7 * ease(v / 0.5), sy=1 - 0.02 * np.exp(-v * 10))
    def cam(self, t): return _shake(t, self.F0, 10, 8), 0.0
    def face(self, t):
        if t < self.C0: return ("open", "open"), None, "mid"
        if t < self.V0:
            d = "・" * (1 + int((t - self.C0) * 3) % 3)
            return T("最短ルート", "算出中" + d)
        if t < self.F0: return T("変形モード", "展開準備") if int(t * 4) % 2 == 0 else T("変形モード", "？")
        return T("システム", "フリーズ") if int((t - self.F0) * 2) % 2 == 0 else (("x", "x"), None, "o")
    def ring(self, t):
        if t < self.V0: return 0.5 + 0.5 * np.sin(2 * np.pi * 2 * t)
        if t < self.F0: return 1.0 if int(t * 12) % 2 == 0 else 0.1
        return 0.0
    def blur(self, t): return self.V0 + 0.4 <= t < self.F0
    def dim(self, t): return 1.0

PATTERNS.update({"片付け": Tidy, "消毒": Spray, "逃げる": Flee, "顔認証": FaceAuth, "省電力": Sleep, "スロット": Slot,
                 "あっちこっち": Torn, "譲り合い": Dither, "壁にお辞儀": GhostBow, "変形": Transform})

# ================= 2026-10-24 追加の20話 =================
_prev_eye = hk.eye_mask
def _eye24(m, kind, box, side, dy):
    if kind == "cyc":                 # 第9話: 外装を外した中の、光る目が1つ（画面の真ん中に大きく）
        if side > 0: return
        cx = (hk.V_EYE_LEFT[0] + hk.V_EYE_RIGHT[2]) / 2
        cy = (hk.V_EYE_LEFT[1] + hk.V_MOUTH[3]) / 2
        cv2.circle(m, hk._p(cx, cy), int(34 * hk.SS), 255, int(5 * hk.SS), cv2.LINE_AA)
        cv2.circle(m, hk._p(cx, cy), int(17 * hk.SS), 255, -1, cv2.LINE_AA)
        return
    return _prev_eye(m, kind, box, side, dy)
hk.eye_mask = _eye24

def _dots(t, t0):
    return "・" * (1 + int((t - t0) * 3) % 3)
def _settle(t, t0, amp=1.0):
    if t < t0: return {}
    v = t - t0; k = np.exp(-v * 10) * np.cos(2 * np.pi * 4 * v)
    return dict(sx=1 + 0.06 * k * amp, sy=1 - 0.06 * k * amp, rot=3 * k * amp)
def _hop(t, t0, h=60, d=0.45):
    v = t - t0
    if v < 0: return {}
    if v < d:
        u = v / d; hh = 4 * u * (1 - u)
        return dict(dy=-h * hh, sy=1 + 0.05 * hh, sx=1 - 0.03 * hh, sh_h=h * hh)
    return _settle(t, t0 + d)

class Announce:         # 第7話: 走ってきて止まり、画面に「ピンポンパンポーン！」→ 音に合わせて体ごと跳ねる → 「回収シマシタ」
    dur = 6.0
    empty = False
    A = 1.6                                    # アナウンスの始まり（ハコブの声の頭）
    NOTES = (0.0, 0.32, 0.64, 0.96)            # ピン・ポン・パン・ポーン（A からの秒）
    B = 3.3                                    # 「検便容器ヲ…」に替わる
    def motion(self, t):
        if t < 1.3: return _approach(t, 1.0, 240)
        m = {}
        for n in self.NOTES:
            v = t - self.A - n
            if 0 <= v < 0.3:
                k = np.sin(np.pi * v / 0.3)
                m = dict(dy=-26 * k, sy=1 + 0.06 * k, sx=1 - 0.04 * k)
        if t >= self.B:
            m = dict(rot=2.0 * np.sin(2 * np.pi * 3 * t), dy=-6 * abs(np.sin(2 * np.pi * 6 * t)))
        return m
    def cam(self, t):
        return sum(_shake(t, self.A + n, 7, 9) for n in self.NOTES), 0.0
    def face(self, t):
        if t < self.A: return ("open", "open"), None, "mid"
        if t < self.B: return T("ピンポン", "パンポーン！")
        return T("検便容器ヲ", "回収シマシタ") if int(t * 4) % 3 else (("chev", "chev"), None, "tri")
    def ring(self, t): return 1.0 if t >= self.A and int(t * 10) % 2 == 0 else 0.4
    def blur(self, t): return False
    def dim(self, t): return 1.0
    def fx(self, t, frame, mo):                # 頭の上から出る音の波（弧が広がる）
        if t < self.A: return frame
        cx = PIVOT[0] + mo.get("dx", 0); cy = _top() + 70 + mo.get("dy", 0)
        for i in range(3):
            ph = ((t - self.A) * 1.6 + i / 3) % 1
            r = int(120 + 260 * ph); a = (1 - ph) * 0.8
            ov = frame.copy()
            for st, en in ((200, 250), (290, 340)):
                cv2.ellipse(ov, (int(cx), int(cy)), (r, r), 0, st, en, (196, 71, 31), 9, cv2.LINE_AA)
            frame = cv2.addWeighted(frame, 1 - a, ov, a, 0)
        return frame

class Glare:            # 第9話: 薄暗い機材室。外装の取れた体の画面に光る目が1つ → じわっと寄る → ギョロッとこっちを向く
    dur = 4.6
    empty = False
    G = 2.6                                    # ギョロ
    tint = (0.42, 0.44, 0.5)
    def motion(self, t):
        u = ease(t / self.G)
        m = dict(s=1 + 0.10 * u, dy=12 * u, sh_y=12 * u)
        if t >= self.G:
            v = t - self.G
            m.update(dx=6 * np.exp(-v * 8) * np.sin(2 * np.pi * 9 * v))
        return m
    def cam(self, t): return _shake(t, self.G, 8, 7), _shake(t, self.G, 4, 8, 27, 1)
    def face(self, t):
        if t < 0.8: return ("none", "none"), None, "none"
        return ("cyc", "cyc"), None, "none"
    def ring(self, t): return 0.0 if t < self.G else 0.25
    def blur(self, t): return False
    def dim(self, t):
        if t < 0.8: return 0.55
        return 0.62 + (0.12 if t >= self.G and int((t - self.G) * 14) % 2 == 0 and t < self.G + 0.5 else 0)

class Ikebo:            # 第17話: 呼ばれてゆっくり近づく → 半目のキメ顔で斜めに止まる（キラーン）
    dur = 5.0
    empty = True
    K = 3.0                                    # 決める
    def motion(self, t):
        if t < self.K:
            u = ease(t / self.K)
            return dict(dx=140 * (1 - u), dy=24 * u, s=1 + 0.05 * u, sh_y=24 * u, rot=-1.5 * np.sin(np.pi * u))
        v = t - self.K
        return dict(dy=24, s=1.05, sh_y=24, rot=-4 * ease(v / 0.35), sx=1 + 0.03 * np.exp(-v * 8))
    def face(self, t):
        if t < self.K: return ("half", "half"), None, "flat"
        return ("half", "half"), None, "flat"
    def ring(self, t): return 0.3 + 0.7 * (t >= self.K and (t - self.K) < 0.5)
    def blur(self, t): return False
    def dim(self, t): return 0.9 if t < self.K else 0.9 + 0.1 * np.exp(-(t - self.K) * 4)
    def fx(self, t, frame, mo):                # キラーン（星が光る）
        v = t - self.K - 0.15
        if not (0 <= v < 0.9): return frame
        a = np.sin(np.pi * v / 0.9)
        cx = PIVOT[0] + mo.get("dx", 0) + 150; cy = _top() + 10 + mo.get("dy", 0)
        ov = frame.copy()
        L = int(70 * a) + 4
        for ang in (0, 90):
            dx_, dy_ = int(L * np.cos(np.radians(ang))), int(L * np.sin(np.radians(ang)))
            cv2.line(ov, (int(cx - dx_), int(cy - dy_)), (int(cx + dx_), int(cy + dy_)), (255, 250, 220), 6, cv2.LINE_AA)
        cv2.circle(ov, (int(cx), int(cy)), int(10 * a) + 2, (255, 255, 240), -1, cv2.LINE_AA)
        return cv2.addWeighted(frame, 1 - a, ov, a, 0)

class Ninja:            # 第23話: 目を閉じたまま音もなく、奥から手前へすーっと滑ってくる → ぴたっと止まる
    dur = 4.6
    empty = True
    S = 3.1                                    # 止まる
    def motion(self, t):
        u = ease(min(t / self.S, 1))
        s = 0.68 + 0.36 * u
        y = -150 * (1 - u) + 22 * u
        return dict(dx=-160 * (1 - u), dy=y, s=s, sh_y=y)
    def face(self, t):
        if t < self.S + 0.9: return ("calm", "calm"), None, "none"
        return T("忍者モード", "解除シマス")
    def ring(self, t): return 0.0 if t < self.S + 0.9 else 0.5
    def blur(self, t): return False
    def dim(self, t): return 1.0

class Menu:             # 第46話: 画面のメニューが ポチ・ポチ と深くなっていく → ピーンポーン（ナースコール）で目が丸く
    dur = 6.0
    empty = False
    P0, STEP = 1.4, 0.55                        # 1回目のポチと間かく
    ROWS = [("設定", "＞運用"), ("運用", "＞出力"), ("出力", "＞音声"), ("音声", "＞音量"), ("音量", "＞時間帯別…")]
    C = 4.4                                     # ナースコール
    def motion(self, t):
        if t < 1.2: return _approach(t, 1.0, 230)
        for i in range(len(self.ROWS)):
            v = t - self.P0 - i * self.STEP
            if 0 <= v < 0.18: return dict(dx=-5 * np.sin(np.pi * v / 0.18), sy=1 - 0.02 * np.sin(np.pi * v / 0.18))
        if t >= self.C: return _settle(t, self.C, 0.8)
        return {}
    def face(self, t):
        if t < self.P0: return T("設定")
        if t < self.C:
            i = min(int((t - self.P0) / self.STEP), len(self.ROWS) - 1)
            return T(*self.ROWS[i])
        if t < self.C + 0.6: return ("big", "big"), None, "o"
        return ("dot", "dot"), None, "flat"
    def ring(self, t): return 1.0 if t >= self.C and int(t * 8) % 2 == 0 else 0.4
    def blur(self, t): return False
    def dim(self, t): return 1.0

class Wobble:           # 第47話: 画面のすみに小さく「右車輪のメンテナンス推奨」→ 進むたびに カタッ と右に傾く
    dur = 5.0
    empty = True
    K = (1.6, 2.6, 3.4, 4.0)                    # カタッ
    def motion(self, t):
        x = 150 * (1 - ease(t / 4.4))
        r = 0.0
        for k in self.K:
            v = t - k
            if v >= 0: r += 9 * np.exp(-v * 7) * np.cos(2 * np.pi * 5 * v)
        return dict(dx=x, rot=-r - 1.5, dy=-3 * abs(np.sin(2 * np.pi * 3 * t)), sh_y=0)
    def face(self, t):
        for k in self.K:
            if k <= t < k + 0.25: return ("big", "big"), None, "o"
        return T("右車輪の", "メンテナンス推奨")
    def ring(self, t): return 0.5
    def blur(self, t): return False
    def dim(self, t): return 1.0

class Diag:             # 第51話: 「目悪くなった？」→ 画面「自己診断中」→「全機能 正常」で得意げにぴょん
    dur = 5.4
    empty = False
    S, R = 2.2, 3.6
    def motion(self, t):
        if t < 1.2: return _approach(t, 1.0, 230)
        if t < self.R: return dict(sy=1 + 0.006 * np.sin(2 * np.pi * 1.4 * t))
        return _hop(t, self.R, 55)
    def face(self, t):
        if t < self.S: return ("open", "open"), None, "mid"
        if t < self.R: return T("自己診断", "実行中" + _dots(t, self.S))
        return T("全機能", "正常") if int((t - self.R) * 3) % 3 else (("happy", "happy"), None, "closed")
    def ring(self, t): return 0.5 + 0.5 * np.sin(2 * np.pi * 2 * t)
    def blur(self, t): return False
    def dim(self, t): return 1.0

class Slot40(Slot):     # 第52話: 画面の数字がクルクル → ジャラン！「夜勤 +40%」
    WORDS = ["+12%", "+25%", "+7%", "+33%", "+18%", "+29%", "+3%", "+36%"]
    def face(self, t):
        if t < self.S0: return ("open", "open"), None, "mid"
        if t < self.S1:
            sp = 4 + 10 * (1 - (t - self.S0) / (self.S1 - self.S0))
            return T("今月ノ夜勤", self.WORDS[int((t - self.S0) * sp) % len(self.WORDS)])
        return T("今月ノ夜勤", "+40%")

class StopGo:           # 第60話: 廊下を元気に行ったり来たり → ピタッ！と止まって動かない
    dur = 4.6
    empty = True
    sub = 4
    P = 2.9                                     # ピタッ
    def x(self, t): return 150 * np.sin(2 * np.pi * 0.55 * t)
    def motion(self, t):
        if t < self.P:
            x = self.x(t); vel = self.x(t + 0.01) - x
            return dict(dx=x, flip=vel > 0, sx=1.05, sy=0.97, rot=-3 * np.sign(vel), dy=-6 * abs(np.sin(2 * np.pi * 4 * t)), sh_y=0)
        x = self.x(self.P); vel = self.x(self.P) - self.x(self.P - 0.01)
        m = _settle(t, self.P, 1.6); m.update(dx=x, flip=vel > 0, sh_y=0); return m
    def cam(self, t): return _shake(t, self.P, 10, 8), 0.0
    def face(self, t):
        if t < self.P: return ("happy", "happy"), None, "closed"
        return ("open", "open"), None, "mid"
    def ring(self, t): return 0.6
    def blur(self, t): return t < self.P
    def dim(self, t): return 1.0

class Recheck:          # 第62話: 「了解シマシタ」で即ダッシュ → 途中で止まって「？」→ 汗をかいて戻ってくる
    dur = 6.0
    empty = True
    sub = 4
    D0, D1, Q, B0, B1 = 1.0, 1.7, 2.0, 3.0, 4.0
    def motion(self, t):
        if t < 1.2: return dict(_approach(t, 1.0, 230), sh_y=0)
        if t < self.D0: return dict(sh_y=0, sy=1 + 0.005 * np.sin(2 * np.pi * 1.3 * t))
        if t < self.D1:
            u = (t - self.D0) / (self.D1 - self.D0)
            return dict(dx=-260 * u * u, dy=-80 * u, s=1 - 0.2 * u, sx=1.08, sy=0.95, rot=6, sh_y=-80 * u)
        if t < self.B0:
            m = _settle(t, self.D1, 1.2); m.update(dx=-260, dy=-80, s=0.8, sh_y=-80); return m
        if t < self.B1:
            u = ease((t - self.B0) / (self.B1 - self.B0))
            return dict(dx=-260 * (1 - u), dy=-80 * (1 - u), s=0.8 + 0.2 * u, flip=True, sh_y=-80 * (1 - u),
                        rot=-3 * np.sin(np.pi * u))
        return dict(sh_y=0)
    def face(self, t):
        if t < self.D0: return ("happy", "happy"), None, "closed"
        if t < self.Q: return ("chev", "chev"), None, "tri"
        if t < self.B0: return T("？")
        return ("dot", "dot"), "trouble", "wave"
    def ring(self, t): return 1.0 if self.D0 <= t < self.D1 and int(t * 12) % 2 == 0 else 0.4
    def blur(self, t): return self.D0 <= t < self.D1 + 0.1
    def dim(self, t): return 1.0

class Reroute(Torn):    # 第63話: 通行止めの前で、あっち・こっちを向いて「経路再計算」をくり返す
    def face(self, t):
        if t < self.F[0]: return ("open", "open"), None, "mid"
        if t < self.STOP:                      # 反転している間は字が鏡文字になるので、目だけ
            n = sum(1 for f in self.F if t >= f)
            return T("経路", "再計算") if n % 2 == 0 else (("big", "big"), None, "o")
        return T("経路", "再計算" + _dots(t, self.STOP))

class Auth:             # 第65話: 道を聞かれて、画面に鍵マーク「職員認証ガ必要デス」→ 首をふるように左右にゆれる
    dur = 4.8
    empty = False
    A = 2.6
    def motion(self, t):
        if t < 1.3: return _approach(t, 1.0, 230)
        if t < self.A: return {}
        v = t - self.A
        return dict(rot=4 * np.sin(2 * np.pi * 2.2 * v) * np.exp(-v * 0.8), dx=5 * np.sin(2 * np.pi * 2.2 * v) * np.exp(-v * 0.8))
    def face(self, t):
        if t < self.A: return ("open", "open"), None, "mid"
        return T("職員認証ガ", "必要デス") if int((t - self.A) * 2.5) % 3 else T("認証", "エラー")
    def ring(self, t): return 1.0 if t >= self.A and int(t * 6) % 2 == 0 else 0.4
    def blur(self, t): return False
    def dim(self, t): return 1.0

class Leave:            # 第71話: 「コチラデス」→ ズギュン！と奥へ走り去り、廊下の奥で小さくなる
    dur = 4.8
    empty = True
    sub = 4
    Z0, Z1 = 1.5, 2.3
    def motion(self, t):
        if t < 1.1: return dict(_approach(t, 0.9, 200), sh_y=0)
        if t < self.Z0: return dict(sh_y=0)
        u = ease((t - self.Z0) / (self.Z1 - self.Z0)) if t < self.Z1 else 1.0
        y = -230 * u
        return dict(dx=-120 * u, dy=y, s=1 - 0.72 * u, sh_y=y, flip=False, rot=-4 * (1 - u) * (t < self.Z1))
    def face(self, t):
        if t < self.Z0: return ("happy", "happy"), None, "closed"
        return ("chev", "chev"), None, "tri"
    def ring(self, t): return 1.0 if t >= self.Z0 and int(t * 12) % 2 == 0 else 0.5
    def blur(self, t): return self.Z0 <= t < self.Z1 + 0.1
    def dim(self, t): return 1.0

class Lead(Follow):     # 第72話: 画面に「目的地 405号室」→ 話しかけられても「経路ヲ継続シマス」でまっすぐ進む
    A = 2.2                                     # 画面の字が替わる時刻（歩き出しは WALK から）
    WALK = 0.15
    def motion(self, t):
        a, self.A = self.A, self.WALK
        try: return Follow.motion(self, t)
        finally: self.A = a
    def face(self, t):
        if t < self.A: return T("目的地", "405号室")
        return T("経路ヲ", "継続シマス")

class Pudding:          # 第84話: 「プリンを持ってきてくれ。至急だ」→「！」→ 空回り → 手前へ突進 → キキーッ「プリンデス」
    dur = 6.0
    empty = True
    sub = 4
    H, D0, D1 = 2.6, 3.2, 3.9                   # 聞こえた・突進
    def motion(self, t):
        if t < self.H: return dict(sh_y=0, sy=1 + 0.005 * np.sin(2 * np.pi * 1.3 * t))
        if t < self.D0:
            v = t - self.H
            return dict(dx=6 * np.sin(2 * np.pi * 18 * v), dy=-abs(np.sin(2 * np.pi * 9 * v)) * 6, sh_y=0)
        if t < self.D1:
            u = ease((t - self.D0) / (self.D1 - self.D0))
            return dict(dx=-30 * u, dy=24 * u, s=1 + 0.05 * u, sx=1.06, sy=0.96, sh_y=24 * u)
        m = _settle(t, self.D1, 1.4); m.update(dx=-30, dy=24, s=1.05, sh_y=24); return m
    def cam(self, t): return _shake(t, self.D1, 9, 8), 0.0
    def face(self, t):
        if t < self.H: return ("open", "open"), None, "mid"
        if t < self.D1: return ("big", "big"), None, "o"
        return T("ゴ注文ノ", "プリンデス") if int((t - self.D1) * 2.5) % 3 else (("happy", "happy"), None, "closed")
    def ring(self, t): return 1.0 if t >= self.H and int(t * 12) % 2 == 0 else 0.4
    def blur(self, t): return self.D0 <= t < self.D1 + 0.1
    def dim(self, t): return 1.0

class LowPri:           # 第89話: 遠慮がちなお願いを最後まで聞く →「優先度：低」→ のんびり反対へ去っていく
    dur = 6.6
    empty = True
    A, G0 = 4.4, 5.0
    def motion(self, t):
        if t < 1.2: return dict(_approach(t, 1.0, 230), sh_y=0)
        if t < self.A: return dict(sh_y=0, rot=1.2 * np.sin(2 * np.pi * 0.4 * t))
        if t < self.G0: return dict(_settle(t, self.A, 0.5), sh_y=0)
        u = ease((t - self.G0) / 1.6)
        return dict(dx=220 * u, dy=-60 * u, s=1 - 0.15 * u, flip=True, sh_y=-60 * u, rot=1.5 * np.sin(2 * np.pi * 2 * t))
    def face(self, t):
        if t < self.A: return ("open", "open"), None, "mid"
        if t < self.G0: return T("優先度", "低")
        return ("happy", "happy"), None, "closed"
    def ring(self, t): return 0.4
    def blur(self, t): return False
    def dim(self, t): return 1.0

class Relay:            # 第90話: 走ってくる →「伝言 要約中」→「指示ニ 誤リアリ」でピカピカ・ガタッ
    dur = 5.0
    empty = False
    S, E = 1.4, 2.6
    def motion(self, t):
        if t < 1.3: return _approach(t, 1.0, 240)
        if t < self.E: return {}
        v = t - self.E
        return dict(dx=8 * np.exp(-v * 4) * np.sin(2 * np.pi * 15 * v), dy=-10 * np.exp(-v * 5))
    def cam(self, t): return _shake(t, self.E, 12, 6), 0.0
    def face(self, t):
        if t < self.S: return ("open", "open"), None, "mid"
        if t < self.E: return T("伝言", "要約中" + _dots(t, self.S))
        return T("指示ニ", "誤リアリ") if int((t - self.E) * 4) % 2 == 0 else T("！")
    def ring(self, t): return 1.0 if t >= self.E and int(t * 10) % 2 == 0 else 0.4
    def blur(self, t): return False
    def dim(self, t): return 1.0

class Pulse:            # 第91話: 画面に心拍の数字 → ドクンドクンに合わせて体がはずむ →「1.8倍」
    dur = 5.0
    empty = False
    S, R = 1.4, 2.6
    BPM = 150
    def _beat(self, t):
        if t < self.S: return 0.0
        ph = ((t - self.S) * self.BPM / 60) % 1
        return np.exp(-ph * 14)
    def motion(self, t):
        if t < 1.3: return _approach(t, 1.0, 230)
        b = self._beat(t)
        return dict(sy=1 + 0.04 * b, sx=1 + 0.025 * b, dy=-8 * b)
    def face(self, t):
        if t < self.S: return ("open", "open"), None, "mid"
        if t < self.R: return T("心拍数", "測定中" + _dots(t, self.S))
        return T("平常時ノ", "1.8倍") if self._beat(t) < 0.5 else T("心拍数", "1.8倍")
    def ring(self, t): return 0.2 + 0.8 * self._beat(t)
    def blur(self, t): return False
    def dim(self, t): return 1.0

class NoErase:          # 第92話: 耳打ちに近寄る → 首をふるように左右にゆれて「記録ハ 消セマセン」
    dur = 5.0
    empty = True
    A = 2.8
    def motion(self, t):
        u = ease(min(t / 1.6, 1))
        m = dict(dx=60 * u, dy=16 * u, s=1 + 0.03 * u, sh_y=16 * u, rot=-5 * u)
        if t >= self.A:
            v = t - self.A
            m.update(rot=-5 * (1 - ease(v / 0.3)) + 4 * np.sin(2 * np.pi * 2.0 * v) * np.exp(-v * 0.6))
        return m
    def face(self, t):
        if t < self.A: return ("open", "open"), None, "mid"
        return T("記録ハ", "消セマセン")
    def ring(self, t): return 0.5
    def blur(self, t): return False
    def dim(self, t): return 1.0

class Guard:            # 第98話: 夜の当直室の前で見張る。時計が進み、空が明ける →「オハヨウゴザイマス」でぴょん
    dur = 6.0
    empty = False
    S, M = 1.6, 4.4                             # 時計が進み始める・朝
    HOURS = ["0:00", "1:00", "2:00", "3:00", "4:00", "5:00", "6:00"]
    def motion(self, t):
        if t < 1.2: return _approach(t, 1.0, 230)
        if t < self.M: return dict(sy=1 + 0.004 * np.sin(2 * np.pi * 0.7 * t))
        return _hop(t, self.M + 0.3, 45)
    def face(self, t):
        if t < self.S: return T("待機中")
        if t < self.M:
            i = min(int((t - self.S) / (self.M - self.S) * len(self.HOURS)), len(self.HOURS) - 1)
            return T("待機中", self.HOURS[i])
        return T("オハヨウ", "ゴザイマス")
    def ring(self, t): return 0.15 if t < self.M else 0.6
    def blur(self, t): return False
    def dim(self, t): return 0.55 + 0.45 * ease((t - self.S) / (self.M - self.S + 0.3))

PATTERNS.update({"アナウンス": Announce, "ギョロ": Glare, "イケボ": Ikebo, "忍者": Ninja, "メニュー": Menu, "カタッ": Wobble,
                 "自己診断": Diag, "夜勤スロット": Slot40, "ピタッ": StopGo, "確信度": Recheck, "経路再計算": Reroute,
                 "職員認証": Auth, "置いてけぼり": Leave, "案内": Lead, "プリン": Pudding, "優先度低": LowPri, "伝言": Relay,
                 "心拍": Pulse, "消せない": NoErase, "見張り": Guard})

WIN = (172 + 6, 937 - 4)   # ショートで使う映像の範囲（mkshort の OP_CROP）。頭の上と足もとに少し余白
CUT = []

def render(pat, out):
    n = int(FPS * pat.dur)
    TINT[0] = tuple(getattr(pat, "tint", (1.0, 1.0, 1.0)))
    HAT[0] = getattr(pat, "hat", None)
    BG = None
    if getattr(pat, "bg", None):                      # 話ごとの背景（_ショート専用/背景/第NN話.png）
        BG = np.array(Image.open(pat.bg).convert("RGB").resize((Ww, Hh), Image.LANCZOS)).astype(np.float32)
    p = subprocess.Popen(["ffmpeg", "-v", "error", "-y", "-f", "rawvideo", "-pix_fmt", "rgb24", "-s", f"{Ww}x{Hh}",
                          "-r", str(FPS), "-i", "-", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "18", out],
                         stdin=subprocess.PIPE)
    for i in range(n):
        t = i / FPS
        eyes, brows, mouth = pat.face(t)
        arr = robot_rgba(eyes, brows, mouth, float(np.clip(pat.ring(t), 0, 1)))
        k = getattr(pat, "sub", SUB) if pat.blur(t) else 1
        acc = np.zeros((Hh, Ww, 4), np.float32)
        f0 = pat.motion(t).get("flip", False)        # 向きはコマごとに1つ（ぶれの中で向きが混ざらないように）
        for s in range(k):
            mo_s = dict(pat.motion(t + (s - (k - 1) / 2) / FPS / k)); mo_s["flip"] = f0
            acc += place(arr, **mo_s)
        acc /= k
        al = acc[..., 3:4] / 255
        ys_, xs_ = np.nonzero(acc[..., 3] > 40)            # 体がショートの映像の窓（y172〜937）からはみ出していないか
        if len(ys_) and (ys_.min() < WIN[0] or ys_.max() > WIN[1] or xs_.min() <= 1 or xs_.max() >= Ww - 2):
            CUT.append((round(t, 2), int(ys_.min()), int(ys_.max())))
        mo = pat.motion(t)
        if BG is not None or getattr(pat, "empty", False):
            fl = mo.get("fall", 0)
            if "sh_y" in mo:      # 手前に来る動き: 影は足もとについてくる
                sh = shadow(mo.get("sh_h", 0), mo.get("dx", 0), mo.get("s", 1), mo["sh_y"])
            else:
                sh = shadow(-mo.get("dy", 0), mo.get("dx", 0) + mo.get("fall_side", -1) * fl * 2.2, 1 + fl / 90 * 0.7)
            base = (BG if BG is not None else plate_empty) * (1 - sh[..., None])
        elif getattr(pat, "clean", False):
            base = plate_clean * (1 - shadow(-mo.get("dy", 0))[..., None])
        else:
            base = plate
        frame = (base * (1 - al) + acc[..., :3]) * pat.dim(t)
        if hasattr(pat, "fx"):                        # 体の上に描き足す（霧・相手のロボットなど。2026-10-20）
            frame = pat.fx(t, np.ascontiguousarray(frame, np.float32), mo)
        if hasattr(pat, "cam"):                       # 画面ごと揺らす（ぶつかったとき）
            cx, cy = pat.cam(t)
            if cx or cy:
                frame = cv2.warpAffine(frame, np.float32([[1, 0, cx], [0, 1, cy]]), (Ww, Hh), borderMode=cv2.BORDER_REFLECT)
        p.stdin.write(np.clip(frame, 0, 255).astype(np.uint8).tobytes())
    p.stdin.close(); p.wait()
    print("書き出し:", out)
    if CUT:
        print(f"  ！体が映像の窓からはみ出す（頭・足が切れる）: {len(CUT)}コマ 例 {CUT[:3]}（秒, 上端, 下端）")
    CUT.clear()

if __name__ == "__main__":
    names = sys.argv[1:] or list(PATTERNS)
    for arg in names:                 # 「名前:dur=6.5,TX=2.0」で時刻を上書きできる（声の長さに合わせる）
        nm, _, kv = arg.partition(":")
        pat = PATTERNS[nm]()
        for item in filter(None, kv.split(",")):
            k, v = item.split("=")
            if k == "bg": setattr(pat, k, v)                       # 背景の画像のパス（「,」「=」を含めない）
            elif "/" in v: setattr(pat, k, tuple(float(x) for x in v.split("/")))
            else: setattr(pat, k, float(v))
        render(pat, f"ハコブくん_{nm}.mp4")
```

### _制作ベース/マンガショート_台本_20261024/common24.py

20話の台本に共通の関数と、背景の使い回しの対応表

```python
import os
W = os.environ.get("W24", os.path.expanduser("~/w"))
A = W + "/anim/"
C = W + "/comics/"
BG = W + "/bg/"
V = "voice/"
def hk(n, sub, **k): return dict(v=V + f"ハコブ/試し_{n:02d}.mp3", who="hk", sub=sub, **k)
def hb(name, n, sub, **k): return dict(v=V + f"{name}/試し_{n:02d}.mp3", who="hk", sub=sub, **k)
def ns(who, n, sub, **k): return dict(v=V + f"{who}/試し_{n:02d}.mp3", who="ns", sub=sub, **k)
def end(*rows, dur=4.4): return dict(kind="end", dur=dur, rows=list(rows))
def W_(text, at, x, y, size=120, color="ink", rot=0, until=99): return dict(text=text, at=at, x=x, y=y, size=size, color=color, rot=rot, until=until)

import subprocess, numpy as np
def vlen(here, rel, est=2.0):
    """声の長さ（mkshort と同じく前後の無音を落とした長さ）。まだ声がないときは est を返す"""
    p = os.path.join(here, rel)
    if not os.path.exists(p): return est
    r = subprocess.run(["ffmpeg", "-v", "error", "-i", p, "-f", "f32le", "-ac", "1", "-ar", "48000", "-"], capture_output=True)
    a = np.abs(np.frombuffer(r.stdout, np.float32))
    if not len(a): return est
    idx = np.where(a > a.max() * 0.06)[0]
    return (idx[-1] - idx[0]) / 48000 + 0.1
# 背景（_ショート専用/背景/ の絵。使い回しはここで決める）
BGMAP = {7: "第7話.png", 9: "第9話.png", 17: "第24話.png", 23: "第67話.png", 46: "第82話.png", 47: "第48話.png",
         51: "第41話.png", 52: "第52話.png", 60: "第83話.png", 62: "第19話.png", 63: "第63話.png", 65: "第22話.png",
         71: "第86話.png", 72: "第72話.png", 84: "bg_empty.png", 89: "第85話.png", 90: "第90話.png", 91: "第91話.png",
         92: "第20話.png", 98: "第98話.png"}
def bg(ep): return BG + BGMAP[ep]
def mot(name, ep, **kw):
    """hakobu_motion.py に渡す引数（時刻は声の長さから決めたもの）"""
    kv = ",".join(f"{k}={'/'.join(f'{x:.2f}' for x in v) if isinstance(v, (tuple, list)) else (f'{v:.2f}' if isinstance(v, float) else v)}" for k, v in kw.items())
    return f"{name}:{kv},bg={bg(ep)}" if kv else f"{name}:bg={bg(ep)}"
def anim(ep, name): return A + f"ep{ep}/ハコブくん_{name}.mp4"
```

### _制作ベース/マンガショート_台本_20261024/build.py

動き・静止画・書き出しをまとめて動かす

```python
"""使い方: python3 build.py anim|still|full [話の番号 …]（番号なしで全部）"""
import os, sys, glob, importlib.util, subprocess
os.environ["W24"] = W = "/home/claude/w"
mode = sys.argv[1]; want = set(int(x) for x in sys.argv[2:])
for d in sorted(glob.glob(W + "/ep/*/")):
    f = glob.glob(d + "spec_ep*.py")[0]
    sp = importlib.util.spec_from_file_location("s", f); S = importlib.util.module_from_spec(sp); sp.loader.exec_module(S)
    if want and S.EP not in want: continue
    if mode == "anim":
        ad = f"{W}/anim/ep{S.EP}"; os.makedirs(ad, exist_ok=True)
        r = subprocess.run(["python3", f"{W}/hakobu_motion.py", S.MOTION], cwd=ad, capture_output=True, text=True)
        print(S.NAME, (r.stdout + r.stderr).strip().replace("\n", " ")[-200:])
    elif mode == "still":
        import re, ast
        r = subprocess.run(["python3", f"{W}/mkshort.py", f, "--still", "0.5"], capture_output=True, text=True)
        sc = re.findall(r"\(([\d.]+)\)?, (?:np\.float64\()?([\d.]+)\)?, '(\w+)'\)", r.stdout.replace("np.float64(", ""))
        evs = [(float(a), float(b), k) for a, b, k in re.findall(r"\(([\d.]+)\)?, ([\d.]+)\)?, '(\w+)'", r.stdout.replace("np.float64(", "").replace(")", ""))]
        ts = [evs[0][1] * 0.6, evs[0][1] - 0.3] + [min(b - 0.1, a + 1.2) for a, b, k in evs[1:]]
        ts = ",".join(f"{t:.2f}" for t in ts)
        for g in glob.glob(d + "still_*.png"): os.remove(g)
        r = subprocess.run(["python3", f"{W}/mkshort.py", f, "--still", ts], capture_output=True, text=True)
        print(S.NAME, (r.stdout + r.stderr).strip()[-300:])
    else:
        r = subprocess.run(["python3", f"{W}/mkshort.py", f], capture_output=True, text=True)
        print(S.NAME, (r.stdout + r.stderr).strip()[-300:])
```

### _制作ベース/マンガショート_台本_20261024/setup_ep.py

話ごとの作業フォルダを用意する

```python
import os, sys, glob, importlib.util, re, subprocess, shutil
os.environ["W24"] = "/home/claude/w"
W = "/home/claude/w"
for f in sorted(glob.glob(W + "/specs/spec_ep*.py")):
    src = open(f).read(); name = re.search(r'NAME, EP = "([^"]+)"', src).group(1)
    d = f"{W}/ep/{name}"; os.makedirs(d, exist_ok=True)
    shutil.copy(f, d)
    for k in ("se", "base"):
        if not os.path.exists(f"{d}/{k}"): os.symlink(f"{W}/{k}", f"{d}/{k}")
    os.makedirs(f"{d}/voice", exist_ok=True)
print("ok")
```

### _制作ベース/マンガショート_台本_20261024/checksubs.py

見出しと字幕の字数・行数を確かめる

```python
import os, sys, glob, importlib.util
os.environ.setdefault("W24", "/home/claude/w")
bad = 0
for f in sorted(glob.glob(os.path.join(os.path.dirname(os.path.abspath(__file__)), "specs/spec_ep*.py"))):
    sp = importlib.util.spec_from_file_location("s", f); S = importlib.util.module_from_spec(sp); sp.loader.exec_module(S)
    for line in S.HEAD:
        n = len(line.replace("【", "").replace("】", ""))
        if n > 11: print(f"{S.NAME} 見出し {n}字: {line}"); bad += 1
    for sc in S.SCENES:
        for l in sc.get("lines", []):
            for pg in l["sub"].split("||"):
                rows = pg.split("|")
                for r in rows:
                    n = len(r.replace("【", "").replace("】", ""))
                    if n > 14: print(f"{S.NAME} 字幕 {n}字: {r}"); bad += 1
                if len(rows) > 3 or (len(rows) == 1 and len(pg.replace('【','').replace('】','')) > 13):
                    print(f"{S.NAME} 行数 {len(rows)}: {pg}"); bad += 1
        for r in sc.get("rows", []):
            w = len(r[0]) * r[1]
            if w > 1000: print(f"{S.NAME} まとめ 幅{w}: {r[0]}"); bad += 1
print("要確認:", bad)
```

### _制作ベース/マンガショート_台本_20261024/spec_ep07.py

```python
import os, sys; sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)) + "/..")
sys.path.insert(0, os.environ.get("W24", os.path.expanduser("~/w")) + "/specs"); from common24 import *
HERE = os.path.dirname(os.path.abspath(__file__)); d = lambda l, e=2.0: vlen(HERE, l["v"], e)
NAME, EP = "第7話_ピンポン", 7
HEAD = ["深刻な告知の最中に", "ロボが【大声で】"]
COMIC = C + "ep07.png"
P = dict(L=(19, 437, 547, 865), R=(554, 437, 1062, 865), B=(19, 872, 1062, 1326))
l1 = ns("田中", 1, "残念ながら、検査の結果が|思わしくなく…。", at=0.15)
a = 0.15 + d(l1, 3.0) + 0.3                       # アナウンスの始まり
l2 = hk(1, "【ピンポンパンポーン！】||検便容器を、|回収してまいりました！", at=a)
l2d = d(l2, 4.5)
B = a + 1.5                                         # 画面が「検便容器ヲ…」に替わる
DUR = a + l2d + 0.5
MOTION = mot("アナウンス", EP, A=a, B=B, dur=DUR)
SCENES = [
  dict(kind="op", video=anim(EP, "アナウンス"), dur=DUR,
       bubble=["残念ながら、", "検査の結果が…"], bubble_until=a,
       lines=[l1, l2],
       words=[W_("ピンポン", a, 300, 330, 120, "orange", -6, a + 0.9), W_("パンポーン！", a + 0.6, 720, 250, 120, "orange", 6, a + 1.6),
              W_("ガナガ!!", a + 1.7, 310, 560, 130, "ink", -8, DUR)],
       se=[dict(at=0.05, f="base/se_whoosh.wav", db=-14), dict(at=a, f="base/se_ding.wav", db=-8)]),
  dict(kind="panel", crop=P["L"], min=1.7, words=[W_("…っ", 0.2, 820, 160, 110, "ink", 4)]),
  dict(kind="panel", crop=(19, 872, 600, 1326), lines=[hk(2, "タスク、完了。")],
       slam=dict(text="【タスク完了】", at=0.2, y=170, size=150)),
  end(("伝える中身は正しくても", 66, "ink", 0.0), ("場の空気しだいで", 74, "ink", 0.25),
      ("最悪の一言になる", 104, "orange", 0.9), ("（音声で何を言うかは、まわりを見て決める）", 38, "gray", 1.8)),
]
```

### _制作ベース/マンガショート_台本_20261024/spec_ep09.py

```python
import os, sys
sys.path.insert(0, os.environ.get("W24", os.path.expanduser("~/w")) + "/specs"); from common24 import *
HERE = os.path.dirname(os.path.abspath(__file__)); d = lambda l, e=2.0: vlen(HERE, l["v"], e)
NAME, EP = "第9話_外装", 9
HEAD = ["外装を外したら", "ロボが【ギョロッ】"]
COMIC = C + "ep09.png"
P = dict(L=(17, 443, 552, 887), B=(17, 894, 1063, 1330))
l1 = ns("高杉", 1, "ハコブくん、今日も|お疲れ様〜。||寝てるかな？", at=0.15)
G = 0.15 + d(l1, 3.0) + 0.4                         # ギョロ
DUR = G + 1.8
MOTION = mot("ギョロ", EP, G=G, dur=DUR)
SCENES = [
  dict(kind="op", video=anim(EP, "ギョロ"), dur=DUR,
       bubble=["ハコブくん、", "寝てるかな？"], bubble_until=G,
       lines=[l1],
       words=[W_("ギョロ…", G, 720, 300, 170, "ink", -6)],
       se=[dict(at=G, f="se/se_don.mp3", db=-8)]),
  dict(kind="panel", crop=P["L"], min=1.8, words=[W_("そーっと…", 0.2, 330, 120, 90, "ink", -4)]),
  dict(kind="panel", crop=(17, 894, 560, 1330), min=1.5, words=[W_("ヒィッ", 0.15, 300, 140, 140, "orange", -8)]),
  dict(kind="panel", crop=(540, 894, 1063, 1330), lines=[ns("槙原", 1, "あ、外装、|外してるだけですよ。"), hk(1, "タスク、完了。")]),
  end(("中身が見えた瞬間", 74, "ink", 0.0), ("かわいいは", 74, "ink", 0.25),
      ("一瞬で消える", 104, "orange", 0.9), ("（外装は、信頼を守る器）", 40, "gray", 1.8)),
]
```

### _制作ベース/マンガショート_台本_20261024/spec_ep17.py

```python
import os, sys
sys.path.insert(0, os.environ.get("W24", os.path.expanduser("~/w")) + "/specs"); from common24 import *
HERE = os.path.dirname(os.path.abspath(__file__)); d = lambda l, e=2.0: vlen(HERE, l["v"], e)
NAME, EP = "第17話_イケボ", 17
HEAD = ["アップデートで", "ロボが【イケボ】に"]
COMIC = C + "ep17.png"
P = dict(T=(20, 20, 700, 460), S=(380, 880, 1060, 1350), Z=(20, 1000, 560, 1350))
l1 = ns("大城", 2, "ハコブくん、こっち来て！", at=0.15)
K = 0.15 + d(l1, 1.6) + 1.0                          # 決める（ゆっくり近づいてから）
l2 = hb("ハコブ_イケボ", 1, "【御意。】", at=K)
l3 = hb("ハコブ_イケボ", 2, "ただいま参上|つかまつった、姫君。", at=K + d(l2, 1.0) + 0.35)
DUR = l3["at"] + d(l3, 2.6) + 0.5
MOTION = mot("イケボ", EP, K=K, dur=DUR)
SCENES = [
  dict(kind="op", video=anim(EP, "イケボ"), dur=DUR,
       bubble=["ハコブくん、", "こっち来て！"], bubble_until=K,
       lines=[l1, l2, l3],
       words=[W_("キラーン", K + 0.15, 760, 200, 120, "orange", 8)],
       se=[dict(at=K + 0.1, f="base/se_ding.wav", db=-8)]),
  dict(kind="panel", crop=P["T"], lines=[ns("寺島", 2, "メーカーいわく、【親しみやすさ】|を重視した||アップデートだそうです。")]),
  dict(kind="panel", crop=P["S"], lines=[ns("大城", 3, "キャラが濃すぎるわ！|前のカタコトに戻して！")],
       slam=dict(text="キャラが濃すぎる!!", at=0.15, y=150, size=120)),
  dict(kind="panel", crop=P["Z"], lines=[hb("ハコブ_イケボ", 3, "……御意。|姫君の、望むままに……。")]),
  end(("親しみやすさは", 74, "ink", 0.0), ("キャラの濃さ", 74, "ink", 0.25),
      ("じゃない", 104, "orange", 0.9), ("（現場が求めるのは、いつも同じにふるまう道具）", 36, "gray", 1.8)),
]
```

### _制作ベース/マンガショート_台本_20261024/spec_ep23.py

```python
import os, sys
sys.path.insert(0, os.environ.get("W24", os.path.expanduser("~/w")) + "/specs"); from common24 import *
HERE = os.path.dirname(os.path.abspath(__file__)); d = lambda l, e=2.0: vlen(HERE, l["v"], e)
NAME, EP = "第23話_忍者", 23
HEAD = ["静音モードにしたら", "ロボが【忍者】に"]
COMIC = C + "ep23.png"
P = dict(L=(22, 437, 518, 939), R=(528, 437, 1058, 939), B=(21, 952, 1058, 1325))
S = 3.1
DUR = S + 0.8
MOTION = mot("忍者", EP, S=S, dur=DUR)
SCENES = [
  dict(kind="op", video=anim(EP, "忍者"), dur=DUR,
       words=[W_("シーン…", 0.2, 760, 180, 110, "ink", 4), W_("スーッ", 1.2, 260, 520, 120, "ink", -8, S)],
       se=[dict(at=0.05, f="base/se_whoosh.wav", db=-26)]),
  dict(kind="panel", crop=P["L"], min=1.6, words=[W_("（無音）", 0.2, 300, 130, 90, "gray", -4)]),
  dict(kind="panel", crop=P["R"], lines=[ns("大城", 1, "【ひっ！】")], min=1.6,
       slam=dict(text="ひっ！", at=0.1, y=180, size=170)),
  dict(kind="panel", crop=(21, 952, 560, 1325), lines=[ns("大城", 2, "静かすぎて、心臓に悪いわ！|少しは音を出して！")]),
  dict(kind="panel", crop=(480, 952, 1058, 1325), lines=[hk(1, "忍者モード、|解除します。")]),
  end(("静かすぎるロボットは", 70, "ink", 0.0), ("かえって", 74, "ink", 0.25),
      ("こわい", 120, "orange", 0.9), ("（気配も、仕様のうち）", 40, "gray", 1.8)),
]
```

### _制作ベース/マンガショート_台本_20261024/spec_ep46.py

```python
import os, sys
sys.path.insert(0, os.environ.get("W24", os.path.expanduser("~/w")) + "/specs"); from common24 import *
HERE = os.path.dirname(os.path.abspath(__file__)); d = lambda l, e=2.0: vlen(HERE, l["v"], e)
NAME, EP = "第46話_メニュー", 46
HEAD = ["音量を下げたいだけ", "なのに【メニューの森】"]
COMIC = C + "ep46.png"
P = dict(L=(25, 379, 562, 884), R=(574, 379, 1057, 884), B=(25, 901, 1057, 1323))
l1 = ns("大城", 1, "ハコブくんの音量、|夜勤用に下げておきたい。", at=0.15)
P0 = 0.15 + d(l1, 2.6) + 0.2
STEP = 0.55
Cl = P0 + 5 * STEP + 0.3                             # ナースコール
DUR = Cl + 1.4
MOTION = mot("メニュー", EP, P0=P0, STEP=STEP, C=Cl, dur=DUR)
SCENES = [
  dict(kind="op", video=anim(EP, "メニュー"), dur=DUR,
       bubble=["音量、", "下げておきたい"], bubble_until=P0,
       lines=[l1],
       words=[W_("ポチ", P0 + i * STEP, 200 + (i % 2) * 650, 300 + i * 70, 90, "ink", -6 + 12 * (i % 2), P0 + i * STEP + 0.5) for i in range(5)]
             + [W_("ピーンポーン", Cl, 540, 220, 130, "orange", -6)],
       se=[dict(at=P0 + i * STEP, f="se/se_pechin.wav", db=-18) for i in range(5)] + [dict(at=Cl, f="base/se_ding.wav", db=-4)]),
  dict(kind="panel", crop=P["L"], fit=False, min=1.8, words=[W_("まだ奥…", 0.2, 760, 120, 90, "orange", 5)]),
  dict(kind="panel", crop=(25, 901, 520, 1323), lines=[ns("大城", 3, "もう、トップ画面に||「よく使う設定」を|3つだけ出して！")]),
  dict(kind="panel", crop=(500, 901, 1057, 1323), lines=[hk(1, "同意します。|深すぎました。")]),
  end(("よく使う設定ほど", 74, "ink", 0.0), ("手前に", 74, "ink", 0.25),
      ("置く", 120, "orange", 0.9), ("（急いでいる人は、深い階層までたどり着けない）", 36, "gray", 1.8)),
]
```

### _制作ベース/マンガショート_台本_20261024/spec_ep47.py

```python
import os, sys
sys.path.insert(0, os.environ.get("W24", os.path.expanduser("~/w")) + "/specs"); from common24 import *
HERE = os.path.dirname(os.path.abspath(__file__)); d = lambda l, e=2.0: vlen(HERE, l["v"], e)
NAME, EP = "第47話_8時間", 47
HEAD = ["8時間ずっと", "【警告】していたのに"]
COMIC = C + "ep47.png"
P = dict(T=(22, 21, 1064, 354), R=(540, 363, 1064, 933), B=(21, 943, 1064, 1332))
l1 = hk(1, "右車輪の、|メンテナンスを推奨します。", at=0.3, db=-8)
t2 = 0.3 + d(l1, 2.8) + 0.25
l2 = ns("寺島", 1, "あ、後でね。", at=t2)
DUR = t2 + d(l2, 1.0) + 1.2
K = (1.5, t2 + 0.6, t2 + 1.3, DUR - 0.5)
MOTION = mot("カタッ", EP, K=K, dur=DUR)
SCENES = [
  dict(kind="op", video=anim(EP, "カタッ"), dur=DUR,
       bubbles=[dict(lines=["あ、後でね"], frm=t2, until=DUR, side="R", y=120)],
       lines=[l1, l2],
       words=[W_("カタッ", k, 230 + 560 * (i % 2), 560 - 60 * i, 110, "ink", -8 + 16 * (i % 2), k + 0.6) for i, k in enumerate(K)],
       se=[dict(at=k, f="se/se_yusa.wav", db=-12) for k in K]),
  dict(kind="panel", crop=P["R"], lines=[ns("大城", 1, "あら、なんか今日、|変だと思ってた。")]),
  dict(kind="panel", crop=(300, 943, 820, 1332), lines=[hk(2, "【8時間前から】、|告知しておりました。")],
       slam=dict(text="8時間前から", at=0.2, y=150, size=130)),
  dict(kind="panel", crop=(21, 943, 455, 1332), skip=[(590, 1020)], lines=[ns("大城", 2, "ごめん、|表示の仕方を変えよう。")]),
  end(("表示しただけでは", 74, "ink", 0.0), ("伝わったことに", 74, "ink", 0.25),
      ("ならない", 120, "orange", 0.9), ("（届いたかどうかまで、設計する）", 40, "gray", 1.8)),
]
```

### _制作ベース/マンガショート_台本_20261024/spec_ep51.py

```python
import os, sys
sys.path.insert(0, os.environ.get("W24", os.path.expanduser("~/w")) + "/specs"); from common24 import *
HERE = os.path.dirname(os.path.abspath(__file__)); d = lambda l, e=2.0: vlen(HERE, l["v"], e)
NAME, EP = "第51話_自己診断", 51
HEAD = ["目が悪くなったのに", "診断は【全部正常】"]
COMIC = C + "ep51.png"
P = dict(R=(550, 424, 1060, 862), B=(20, 880, 1060, 1326))
l1 = ns("大城", 1, "ハコブくん、|目、悪くなった？", at=0.15)
S = 0.15 + d(l1, 1.8) + 0.2
R = S + 1.3
l2 = hk(1, "自己診断結果、|【全機能、正常】。", at=R)
DUR = R + d(l2, 2.2) + 0.6
MOTION = mot("自己診断", EP, S=S, R=R, dur=DUR)
SCENES = [
  dict(kind="op", video=anim(EP, "自己診断"), dur=DUR,
       bubble=["ハコブくん、", "目、悪くなった？"], bubble_until=S,
       lines=[l1, l2],
       words=[W_("ドヤッ", R + 0.3, 800, 260, 120, "orange", 8)],
       se=[dict(at=S, f="se/se_boot.wav", db=-14), dict(at=R, f="base/se_ding.wav", db=-10)]),
  dict(kind="panel", crop=P["R"], min=1.9, words=[W_("レンズに", 0.15, 300, 110, 90, "ink", -4), W_("ホコリ", 0.5, 300, 220, 130, "orange", -4)]),
  dict(kind="panel", crop=(20, 880, 560, 1326), lines=[hk(2, "視界不良を、|検知できませんでした。"), hk(3, "自分の目は、|自分で見えない。")]),
  dict(kind="panel", crop=(540, 880, 1060, 1326), lines=[ns("大城", 2, "私たちが、|定期的に拭くわね。")]),
  end(("センサーの汚れは", 74, "ink", 0.0), ("そのセンサーでは", 74, "ink", 0.25),
      ("見つけられない", 104, "orange", 0.9), ("（外から点検する人が要る）", 40, "gray", 1.8)),
]
```

### _制作ベース/マンガショート_台本_20261024/spec_ep52.py

```python
import os, sys
sys.path.insert(0, os.environ.get("W24", os.path.expanduser("~/w")) + "/specs"); from common24 import *
HERE = os.path.dirname(os.path.abspath(__file__)); d = lambda l, e=2.0: vlen(HERE, l["v"], e)
NAME, EP = "第52話_夜勤", 52
HEAD = ["気をきかせたロボが", "【シフト表】を見ていた"]
COMIC = C + "ep52.png"
P = dict(L=(35, 362, 609, 878), R=(618, 366, 1065, 878), B=(35, 897, 1047, 1320))
l1 = hk(1, "大城様、今月の夜勤は、|過去6カ月平均より、||【40パーセント】多いです。", at=0.9)
L1 = d(l1, 5.0)
S0, S1 = 1.3, 0.9 + L1 * 0.78                         # 「40パーセント」のあたりで止まる
DUR = 0.9 + L1 + 0.7
MOTION = mot("夜勤スロット", EP, S0=S0, S1=S1, dur=DUR)
SCENES = [
  dict(kind="op", video=anim(EP, "夜勤スロット"), dur=DUR,
       lines=[l1],
       words=[W_("+40%", S1, 780, 260, 170, "orange", 8)],
       se=[dict(at=S0, f="se/se_spin.wav", db=-14), dict(at=S1, f="se/se_jaran.wav", db=-8)]),
  dict(kind="panel", crop=P["L"], lines=[ns("大城", 1, "ありがた…いけど、|どこまで見るの？"), hk(2, "シフト表を、|閲覧しました。")]),
  dict(kind="panel", crop=P["R"], lines=[ns("大城", 2, "悪気はないのよね…。|でも、線引きしようね。")]),
  dict(kind="panel", crop=(35, 897, 560, 1320), lines=[ns("大城", 3, "私のデータ見たら、|見たって先に言って。")]),
  dict(kind="panel", crop=(520, 897, 1047, 1320), lines=[hk(3, "以後、参照する前に、|告知します。")]),
  end(("気づかいでも", 74, "ink", 0.0), ("どこで知ったかが", 74, "ink", 0.25),
      ("見えないとこわい", 104, "orange", 0.9), ("（データを見る前に、ひとこと言う）", 40, "gray", 1.8)),
]
```

### _制作ベース/マンガショート_台本_20261024/spec_ep60.py

```python
import os, sys
sys.path.insert(0, os.environ.get("W24", os.path.expanduser("~/w")) + "/specs"); from common24 import *
HERE = os.path.dirname(os.path.abspath(__file__)); d = lambda l, e=2.0: vlen(HERE, l["v"], e)
NAME, EP = "第60話_再現しない", 60
HEAD = ["見張っていると", "【バグが起きない】"]
COMIC = C + "ep60.png"
P = dict(T=(21, 29, 1055, 451), L=(21, 459, 501, 916), B=(21, 924, 1055, 1321))
Pp = 2.6
l1 = ns("大城", 2, "白石さーん！|戻ってきて！", at=Pp + 0.5)
DUR = l1["at"] + d(l1, 1.8) + 0.4
MOTION = mot("ピタッ", EP, P=Pp, dur=DUR)
SCENES = [
  dict(kind="op", video=anim(EP, "ピタッ"), dur=DUR,
       lines=[l1],
       words=[W_("ピタッ", Pp, 540, 240, 190, "ink", -6)],
       se=[dict(at=Pp, f="se/se_skid.wav", db=-8)]),
  dict(kind="panel", crop=(450, 29, 1055, 451), lines=[ns("白石", 1, "再現条件、|調べに来ました！")]),
  dict(kind="panel", crop=P["L"], lines=[ns("白石", 2, "…起きないですね。")],
       words=[W_("2日張り込み", 0.15, 300, 840, 90, "orange", -4)]),
  dict(kind="panel", crop=(21, 924, 560, 1321), lines=[ns("白石", 3, "廊下のWi-Fiの|切り替えの時の挙動でした…。||次回も録画、|お願いします！")]),
  dict(kind="panel", crop=(330, 924, 800, 1321), lines=[hk(1, "現場の目が、|最強のセンサーです。")]),
  end(("見ていない時にだけ", 70, "ink", 0.0), ("起きる不具合は", 74, "ink", 0.25),
      ("現場の目で捕まえる", 100, "orange", 0.9), ("（録画をたのめる関係を作っておく）", 40, "gray", 1.8)),
]
```

### _制作ベース/マンガショート_台本_20261024/spec_ep62.py

```python
import os, sys
sys.path.insert(0, os.environ.get("W24", os.path.expanduser("~/w")) + "/specs"); from common24 import *
HERE = os.path.dirname(os.path.abspath(__file__)); d = lambda l, e=2.0: vlen(HERE, l["v"], e)
NAME, EP = "第62話_確信度", 62
HEAD = ["「了解」と言ったのに", "自信は【62%】だった"]
COMIC = C + "ep62.png"
P = dict(R=(552, 424, 1060, 862), B=(20, 882, 1060, 1325))
l1 = ns("大城", 1, "ノルバスクを、|三番の保管庫まで、お願い。", at=0.15)
t2 = 0.15 + d(l1, 2.6) + 0.25
l2 = hk(1, "了解しました。", at=t2)
D0 = t2 + d(l2, 1.0) + 0.1
D1, Q = D0 + 0.7, D0 + 1.0
B0 = D1 + 1.3; B1 = B0 + 1.0
DUR = B1 + 0.5
MOTION = mot("確信度", EP, D0=D0, D1=D1, Q=Q, B0=B0, B1=B1, dur=DUR)
SCENES = [
  dict(kind="op", video=anim(EP, "確信度"), dur=DUR,
       bubble=["ノルバスクを、", "三番の保管庫まで"], bubble_until=t2,
       lines=[l1, l2],
       words=[W_("ダッ", D0, 300, 520, 120, "ink", -8, D1 + 0.2), W_("確信度62%", Q, 540, 230, 120, "orange", -5)],
       se=[dict(at=D0, f="se/se_dash.mp3", db=-8), dict(at=Q, f="se/se_pikon.mp3", db=-10)]),
  dict(kind="panel", crop=P["R"], lines=[hk(2, "確認します。|薬剤名を、もう一度。"), ns("大城", 2, "なんで最初に言わないの！|二度手間よ！")]),
  dict(kind="panel", crop=(60, 882, 600, 1325), lines=[hk(3, "聞き取れました。|【確信度、低】。")]),
  dict(kind="panel", crop=(460, 882, 1000, 1325), lines=[ns("大城", 3, "その方がいいの。||迷ってるなら、|迷ってる顔して。")]),
  end(("まちがえる前に", 74, "ink", 0.0), ("「自信がない」と", 74, "ink", 0.25),
      ("言える機械", 110, "orange", 0.9), ("（迷いを、外に見せる設計）", 40, "gray", 1.8)),
]
```

### _制作ベース/マンガショート_台本_20261024/spec_ep63.py

```python
import os, sys
sys.path.insert(0, os.environ.get("W24", os.path.expanduser("~/w")) + "/specs"); from common24 import *
HERE = os.path.dirname(os.path.abspath(__file__)); d = lambda l, e=2.0: vlen(HERE, l["v"], e)
NAME, EP = "第63話_通行止め", 63
HEAD = ["地図にない壁の前で", "【再計算】が止まらない"]
COMIC = C + "ep63.png"
P = dict(T=(20, 20, 1060, 400), R=(540, 424, 1060, 864), B=(20, 884, 1060, 1325))
l1 = hk(1, "経路、再計算。", at=0.4)
l2 = hk(2, "経路、再計算。|経路、再計算。", at=0.4 + d(l1, 1.3) + 0.15)
STOP = l2["at"] + d(l2, 2.4) + 0.1
F = tuple(round(x * STOP / 3.72, 2) for x in (0.35, 1.5, 2.1, 2.5, 2.8, 3.05, 3.25, 3.42, 3.56))
DUR = STOP + 1.4
MOTION = mot("経路再計算", EP, F=F, STOP=STOP, dur=DUR)
SCENES = [
  dict(kind="op", video=anim(EP, "経路再計算"), dur=DUR,
       lines=[l1, l2],
       words=[W_("ウロウロ", 0.5, 760, 200, 110, "ink", 6, STOP), W_("ピタッ…", STOP, 300, 240, 110, "ink", -6)]),
  dict(kind="panel", crop=(20, 20, 568, 400), skip=[(690, 400)], min=1.8, words=[W_("昨日から通行止め", 0.15, 540, 90, 90, "orange", -3)]),
  dict(kind="panel", crop=P["R"], lines=[ns("高杉", 1, "そこ、昨日から|壁なのよ。"), hk(3, "掲示は、読めます。||地図は、|更新されていません。")]),
  dict(kind="panel", crop=(300, 884, 1060, 1325), min=2.0, words=[W_("手順に1行", 0.2, 560, 110, 120, "orange", -5)]),
  end(("通路を変えたら", 74, "ink", 0.0), ("ロボの地図も", 74, "ink", 0.25),
      ("同じ日に直す", 110, "orange", 0.9), ("（善意ではなく、作業の手順に入れる）", 38, "gray", 1.8)),
]
```

### _制作ベース/マンガショート_台本_20261024/spec_ep65.py

```python
import os, sys
sys.path.insert(0, os.environ.get("W24", os.path.expanduser("~/w")) + "/specs"); from common24 import *
HERE = os.path.dirname(os.path.abspath(__file__)); d = lambda l, e=2.0: vlen(HERE, l["v"], e)
NAME, EP = "第65話_道案内", 65
HEAD = ["道を聞いただけで", "【職員認証】を要求"]
COMIC = C + "ep65.png"
P = dict(R=(576, 383, 1051, 869), B=(31, 877, 1051, 1315))
l1 = ns("森田茂", 1, "すいません、|304号室は、どっちですか。", at=1.2)
Aa = 1.2 + d(l1, 2.4) + 0.25
l2 = hk(1, "【職員認証が、必要です】。", at=Aa)
DUR = Aa + d(l2, 1.8) + 0.8
MOTION = mot("職員認証", EP, A=Aa, dur=DUR)
SCENES = [
  dict(kind="op", video=anim(EP, "職員認証"), dur=DUR,
       bubble=["すいません、", "304号室は？"], bubble_from=1.2, bubble_until=Aa,
       lines=[l1, l2],
       words=[W_("ブブー", Aa, 780, 260, 130, "orange", 8, Aa + 1.2)],
       se=[dict(at=Aa, f="se/se_buzz.wav", db=-10)]),
  dict(kind="panel", crop=P["R"], min=1.7, words=[W_("……", 0.2, 250, 120, 120, "ink", 0)]),
  dict(kind="panel", crop=(31, 877, 560, 1315), lines=[ns("大城", 1, "道案内くらい、|誰にでも答えていいと思うの。")]),
  dict(kind="panel", crop=(420, 877, 1051, 1315), lines=[hk(2, "権限を、|機能ごとに分けます。")]),
  end(("守ることと", 74, "ink", 0.0), ("閉じることは", 74, "ink", 0.25),
      ("ちがう", 120, "orange", 0.9), ("（道案内に、職員の鍵はいらない）", 40, "gray", 1.8)),
]
```

### _制作ベース/マンガショート_台本_20261024/spec_ep71.py

```python
import os, sys
sys.path.insert(0, os.environ.get("W24", os.path.expanduser("~/w")) + "/specs"); from common24 import *
HERE = os.path.dirname(os.path.abspath(__file__)); d = lambda l, e=2.0: vlen(HERE, l["v"], e)
NAME, EP = "第71話_置いてけぼり", 71
HEAD = ["道案内をしたら", "【誰もついてこない】"]
COMIC = C + "ep71.png"
P = dict(L=(20, 424, 527, 862), R=(552, 424, 1060, 862), B=(20, 882, 1060, 1325))
l1 = hk(1, "こちらです。", at=0.95)
Z0 = 0.95 + d(l1, 0.9) + 0.1
DUR = Z0 + 2.0
MOTION = mot("置いてけぼり", EP, Z0=Z0, Z1=Z0 + 0.8, dur=DUR)
SCENES = [
  dict(kind="op", video=anim(EP, "置いてけぼり"), dur=DUR,
       lines=[l1],
       words=[W_("ズギュン", Z0 + 0.1, 700, 260, 150, "ink", -8)],
       se=[dict(at=Z0, f="se/se_dash.mp3", db=-6)]),
  dict(kind="panel", crop=P["L"], lines=[ns("森田久美子", 1, "あら、|ロボットさんは？")]),
  dict(kind="panel", crop=P["R"], lines=[ns("森田久美子", 2, "速いのよ、あなた。||こっちは荷物|持ってるんだから。"), hk(2, "経路は、最短です。")]),
  dict(kind="panel", crop=(180, 882, 860, 1325), lines=[hk(3, "合わせます。"), ns("森田久美子", 3, "そう、それでいいの。")]),
  end(("案内の速さは", 74, "ink", 0.0), ("案内される人が", 74, "ink", 0.25),
      ("決める", 120, "orange", 0.9), ("（荷物・足もと・歩く速さに合わせる）", 38, "gray", 1.8)),
]
```

### _制作ベース/マンガショート_台本_20261024/spec_ep72.py

```python
import os, sys
sys.path.insert(0, os.environ.get("W24", os.path.expanduser("~/w")) + "/specs"); from common24 import *
HERE = os.path.dirname(os.path.abspath(__file__)); d = lambda l, e=2.0: vlen(HERE, l["v"], e)
NAME, EP = "第72話_トイレ", 72
HEAD = ["トイレに行きたいのに", "【案内が止まらない】"]
COMIC = C + "ep72.png"
P = dict(R=(615, 377, 1049, 846), B=(380, 863, 1049, 1324))
l1 = hk(1, "目的地、405号室。|案内中。", at=0.2)
t2 = 0.2 + d(l1, 2.0) + 0.2
l2 = ns("中村", 1, "ちょっと、トイレどこ？", at=t2)
t3 = t2 + d(l2, 1.4) + 0.2
l3 = hk(2, "【経路を、継続します】。", at=t3)
t4 = t3 + d(l3, 1.6) + 0.15
l4 = hk(3, "【経路を、継続します】。", at=t4)
DUR = t4 + d(l4, 1.6) + 0.5
MOTION = mot("案内", EP, A=t3, STEP=round((DUR - t3) / 9, 3), dur=DUR)
SCENES = [
  dict(kind="op", video=anim(EP, "案内"), dur=DUR,
       bubbles=[dict(lines=["ちょっと、", "トイレどこ？"], frm=t2, until=DUR, side="R", y=110)],
       lines=[l1, l2, l3, l4],
       words=[W_("スタスタ", t3 + 0.2, 220, 420, 110, "ink", -8)]),
  dict(kind="panel", crop=P["R"], lines=[ns("中村", 2, "置いてかれるかと|思った。")]),
  dict(kind="panel", crop=P["B"], lines=[ns("大城", 1, "案内中でも、|寄り道は入るのよ。"), ns("白石", 1, "あ、トイレ…。|盲点でした。")]),
  end(("人は", 74, "ink", 0.0), ("まっすぐには", 74, "ink", 0.25),
      ("歩かない", 120, "orange", 0.9), ("（寄り道と再開を、最初から設計する）", 38, "gray", 1.8)),
]
```

### _制作ベース/マンガショート_台本_20261024/spec_ep84.py

```python
import os, sys
sys.path.insert(0, os.environ.get("W24", os.path.expanduser("~/w")) + "/specs"); from common24 import *
HERE = os.path.dirname(os.path.abspath(__file__)); d = lambda l, e=2.0: vlen(HERE, l["v"], e)
NAME, EP = "第84話_プリン", 84
HEAD = ["声まねで頼んだら", "【本物の先生】に届いた"]
COMIC = C + "ep84.png"
P = dict(R=(550, 424, 1060, 862), B=(20, 882, 1060, 1325))
l1 = ns("田中", 1, "ハコブくん、プリンを|持ってきてくれ。至急だ。", at=0.15)
H_ = 0.15 + d(l1, 2.8) + 0.15
l2 = hk(1, "田中先生の声と、一致。|了解しました。", at=H_ + 0.2)
D0 = l2["at"] + d(l2, 2.4) + 0.1
D1 = D0 + 0.7
DUR = D1 + 1.6
MOTION = mot("プリン", EP, H=H_, D0=D0, D1=D1, dur=DUR)
SCENES = [
  dict(kind="op", video=anim(EP, "プリン"), dur=DUR,
       bubble=["プリンを持ってきて", "くれ。至急だ"], bubble_until=H_,
       lines=[l1, l2],
       words=[W_("！", H_, 820, 220, 200, "orange", 8, D0), W_("ギューン", D0, 300, 520, 130, "ink", -8, D1 + 0.4)],
       se=[dict(at=H_, f="se/se_pikon.mp3", db=-8), dict(at=D0, f="se/se_dash.mp3", db=-6), dict(at=D1, f="se/se_skid.wav", db=-10)]),
  dict(kind="panel", crop=P["R"], lines=[hk(2, "ご注文の、|プリンです。"), ns("田中", 2, "【頼んでない】。")]),
  dict(kind="panel", crop=(480, 882, 1060, 1325), lines=[ns("田中", 3, "…俺の声、|そんなに似てたか？")]),
  dict(kind="panel", crop=(20, 882, 640, 1325), lines=[ns("中村", 1, "けっこう、|自信あります。"), hk(3, "以後、声だけでは、|受け付けません。")]),
  end(("声が似ていることは", 70, "ink", 0.0), ("本人の証明に", 74, "ink", 0.25),
      ("ならない", 120, "orange", 0.9), ("（受け付けることと、許可することは別）", 38, "gray", 1.8)),
]
```

### _制作ベース/マンガショート_台本_20261024/spec_ep89.py

```python
import os, sys
sys.path.insert(0, os.environ.get("W24", os.path.expanduser("~/w")) + "/specs"); from common24 import *
HERE = os.path.dirname(os.path.abspath(__file__)); d = lambda l, e=2.0: vlen(HERE, l["v"], e)
NAME, EP = "第89話_お手すき", 89
HEAD = ["遠慮して頼んだら", "【本当に後回し】に"]
COMIC = C + "ep89.png"
P = dict(R=(550, 424, 1060, 862), B=(20, 882, 1060, 1325))
l1 = ns("小芝", 1, "あの…もしお手すき|でしたら、||ルートのセットを…。||あ、急がなくて|大丈夫なんですけど…。", at=0.9)
Aa = 0.9 + d(l1, 5.5) + 0.25
l2 = hk(1, "了解しました。|【優先度、低】で登録します。", at=Aa)
G0 = Aa + d(l2, 2.4) + 0.1
DUR = G0 + 1.1
MOTION = mot("優先度低", EP, A=Aa, G0=G0, dur=DUR)
SCENES = [
  dict(kind="op", video=anim(EP, "優先度低"), dur=DUR,
       bubble=["もしお手すき", "でしたら…"], bubble_from=0.9, bubble_until=Aa,
       lines=[l1, l2],
       words=[W_("のんびり", G0 + 0.2, 300, 420, 110, "ink", -8)]),
  dict(kind="panel", crop=P["R"], skip=[(740, 900)], lines=[ns("大城", 1, "先生、セットは！？")], min=1.8),
  dict(kind="panel", crop=(20, 882, 560, 1325), lines=[ns("大城", 2, "急ぎなら、|【急ぎって言いなさい！】"), ns("小芝", 3, "…急ぎです。")]),
  dict(kind="panel", crop=(500, 882, 1060, 1325), lines=[hk(2, "以後、言い方ではなく、|「急ぎですか」と聞きます。")]),
  end(("急ぎの用ほど", 74, "ink", 0.0), ("人は遠慮して", 74, "ink", 0.25),
      ("やんわり言う", 110, "orange", 0.9), ("（機械の側から「急ぎですか」と聞く）", 38, "gray", 1.8)),
]
```

### _制作ベース/マンガショート_台本_20261024/spec_ep90.py

```python
import os, sys
sys.path.insert(0, os.environ.get("W24", os.path.expanduser("~/w")) + "/specs"); from common24 import *
HERE = os.path.dirname(os.path.abspath(__file__)); d = lambda l, e=2.0: vlen(HERE, l["v"], e)
NAME, EP = "第90話_伝言", 90
HEAD = ["遠慮した伝言が", "【断定】になって届いた"]
COMIC = C + "ep90.png"
P = dict(T=(20, 20, 1060, 400), R=(550, 424, 1060, 862), B=(20, 882, 1060, 1325))
l1 = hk(1, "小芝先生より、伝言です。|要約します。", at=1.2)
E = 1.2 + d(l1, 2.6) + 0.25
l2 = hk(2, "【田中先生の指示に、|誤りがあります】。", at=E)
DUR = E + d(l2, 2.4) + 0.6
MOTION = mot("伝言", EP, S=1.3, E=E, dur=DUR)
SCENES = [
  dict(kind="op", video=anim(EP, "伝言"), dur=DUR,
       lines=[l1, l2],
       words=[W_("ズバッ", E, 760, 240, 160, "orange", 8)],
       se=[dict(at=0.05, f="base/se_whoosh.wav", db=-14), dict(at=E, f="se/se_don.mp3", db=-8)]),
  dict(kind="panel", crop=(20, 20, 780, 400), lines=[ns("小芝", 1, "田中先生に…見間違いかも|しれないんですけど…||お時間ある時にでも…と、|伝えてもらえますか。")],
       words=[W_("元の伝言", 0.1, 880, 820, 80, "gray", 4)]),
  dict(kind="panel", crop=(20, 882, 560, 1325), lines=[ns("田中", 1, "確かに、間違ってた。|助かったよ。||…次は、|直接言いに来い。")]),
  dict(kind="panel", crop=(380, 882, 1060, 1325), lines=[hk(3, "伝言は、要約せずに|届けます。")]),
  end(("伝言から", 74, "ink", 0.0), ("ためらいが消えると", 74, "ink", 0.25),
      ("意味が変わる", 110, "orange", 0.9), ("（言い方ごと、届ける）", 40, "gray", 1.8)),
]
```

### _制作ベース/マンガショート_台本_20261024/spec_ep91.py

```python
import os, sys
sys.path.insert(0, os.environ.get("W24", os.path.expanduser("~/w")) + "/specs"); from common24 import *
HERE = os.path.dirname(os.path.abspath(__file__)); d = lambda l, e=2.0: vlen(HERE, l["v"], e)
NAME, EP = "第91話_心拍数", 91
HEAD = ["緊張を隠していたら", "ロボが【心拍数】を発表"]
COMIC = C + "ep91.png"
P = dict(R=(550, 424, 1060, 862), B=(20, 882, 1060, 1325))
l1 = ns("小芝", 1, "チクッとしますね〜。", at=1.1)
S = 1.1 + d(l1, 1.5) + 0.1
R = S + 1.1
l2 = hk(1, "小芝先生の心拍数、|【平常時の1.8倍】です。", at=R)
DUR = R + d(l2, 2.6) + 0.6
MOTION = mot("心拍", EP, S=S, R=R, dur=DUR)
SCENES = [
  dict(kind="op", video=anim(EP, "心拍"), dur=DUR,
       bubble=["チクッと", "しますね〜"], bubble_from=1.1, bubble_until=S + 0.3,
       lines=[l1, l2],
       words=[W_("ドクン", S + 0.1, 240, 300, 110, "orange", -8, R), W_("ドクン", S + 0.5, 830, 380, 110, "orange", 8, R),
              W_("1.8倍", R + 0.9, 780, 230, 170, "orange", 6)],
       se=[dict(at=R + 0.9, f="base/se_ding.wav", db=-10)]),
  dict(kind="panel", crop=P["R"], lines=[ns("中村", 1, "先生、俺より|緊張してません？"), ns("小芝", 2, "し、してません！")]),
  dict(kind="panel", crop=(20, 882, 540, 1325), lines=[ns("大城", 1, "そういうのは本人にだけ、||こっそり教えてあげて。")]),
  dict(kind="panel", crop=(400, 882, 940, 1325), lines=[hk(2, "以後、本人にだけ|伝えます。")]),
  end(("正しい情報でも", 74, "ink", 0.0), ("届け先をまちがえると", 70, "ink", 0.25),
      ("暴露になる", 110, "orange", 0.9), ("（誰に伝えるかを、先に決めておく）", 40, "gray", 1.8)),
]
```

### _制作ベース/マンガショート_台本_20261024/spec_ep92.py

```python
import os, sys
sys.path.insert(0, os.environ.get("W24", os.path.expanduser("~/w")) + "/specs"); from common24 import *
HERE = os.path.dirname(os.path.abspath(__file__)); d = lambda l, e=2.0: vlen(HERE, l["v"], e)
NAME, EP = "第92話_なかったことに", 92
HEAD = ["ミスを消してと", "頼んだら【断られた】"]
COMIC = C + "ep92.png"
P = dict(R=(540, 411, 1060, 864), B=(20, 868, 1060, 1340))
l1 = ns("小芝", 1, "ハコブくん…さっきの、|なかったことに、できない？", at=0.4)
Aa = 0.4 + d(l1, 3.0) + 0.25
l2 = hk(1, "【記録は、消せません】。", at=Aa)
DUR = Aa + d(l2, 1.6) + 0.8
MOTION = mot("消せない", EP, A=Aa, dur=DUR)
SCENES = [
  dict(kind="op", video=anim(EP, "消せない"), dur=DUR,
       bubble=["さっきの、", "なかったことに…"], bubble_from=0.4, bubble_until=Aa,
       lines=[l1, l2],
       words=[W_("ヒソヒソ", 0.6, 820, 520, 90, "gray", 6, Aa)],
       se=[dict(at=Aa, f="se/se_buzz.wav", db=-14)]),
  dict(kind="panel", crop=P["R"], lines=[hk(2, "ですが、報告は、|一緒にできます。")],
       words=[W_("ガクッ", 0.25, 230, 300, 120, "ink", -8)]),
  dict(kind="panel", crop=(380, 868, 1060, 1340),
       lines=[ns("大城", 1, "言いに来てくれて、|助かるわ。||怒らないから、|次も言いに来てね。")]),
  end(("消せない記録は", 74, "ink", 0.0), ("責める材料ではなく", 74, "ink", 0.25),
      ("報告の足がかり", 104, "orange", 0.9), ("（ミスを言いに来やすい仕組み）", 40, "gray", 1.8)),
]
```

### _制作ベース/マンガショート_台本_20261024/spec_ep98.py

```python
import os, sys
sys.path.insert(0, os.environ.get("W24", os.path.expanduser("~/w")) + "/specs"); from common24 import *
HERE = os.path.dirname(os.path.abspath(__file__)); d = lambda l, e=2.0: vlen(HERE, l["v"], e)
NAME, EP = "第98話_起こさない", 98
HEAD = ["「絶対起こさないで」を", "ロボが【守りすぎた】"]
COMIC = C + "ep98.png"
P = dict(R=(550, 424, 1060, 862), B=(20, 882, 1060, 1340))
l1 = ns("小芝", 1, "朝まで、|【絶対に】起こさないでね。", at=0.9)
t2 = 0.9 + d(l1, 2.0) + 0.2
l2 = hk(1, "了解しました。", at=t2)
S = t2 + d(l2, 1.0) + 0.2
M = S + 2.4
DUR = M + 1.1
MOTION = mot("見張り", EP, S=S, M=M, dur=DUR)
SCENES = [
  dict(kind="op", video=anim(EP, "見張り"), dur=DUR,
       bubble=["朝まで、絶対に", "起こさないでね"], bubble_from=0.9, bubble_until=t2,
       lines=[l1, l2],
       words=[W_("チッ…チッ…", S + 0.3, 760, 260, 90, "gray", 5, M), W_("朝", M + 0.1, 820, 240, 170, "orange", 8)],
       se=[dict(at=M + 0.3, f="se/se_boot.wav", db=-12)]),
  dict(kind="panel", crop=P["R"], lines=[hk(2, "おはようございます。||先生の【承認待ち】が、|3件ありました。")]),
  dict(kind="panel", crop=(150, 882, 860, 1340),
       lines=[ns("小芝", 2, "そういう時は、|起こしてよ！"), hk(3, "「絶対に」と、|言われました。"),
              ns("小芝", 3, "…絶対は、|取り消します。"), hk(4, "起こすべき時は、|起こします。")]),
  end(("「絶対に」にも", 74, "ink", 0.0), ("破っていい", 74, "ink", 0.25),
      ("例外がある", 110, "orange", 0.9), ("（例外の条件を、先に決めておく）", 40, "gray", 1.8)),
]
```

### _制作ベース/voice_design.py

声をデザインして保存する。中村・森田茂・森田久美子・ハコブ_イケボの説明を足した

```python
"""ElevenLabs の Voice Design で、登場人物の声の候補を作り、気に入った候補を声として保存する。エイイチの Mac で動かす。
準備: ターミナルで  read -s ELEVENLABS_API_KEY && export ELEVENLABS_API_KEY

使い方（_制作ベース のフォルダで）
  候補を作る:  python3 voice_design.py design 大城      → 声の候補/大城/候補1.mp3〜候補3.mp3 を聞き比べる
               python3 voice_design.py design 大城 --seed 12   （気に入るものがなければ seed を変えてもう一度）
  保存する:    python3 voice_design.py save 大城 2       → 候補2 を声にして、声の一覧.json に Voice ID を書く
人物の説明と試しの文は、下の CAST に書く（英語の説明の方が安定する）
"""
import argparse, base64, json, os, sys, urllib.request, urllib.error

CAST = {
    "大城": dict(
        desc="A Japanese woman in her late twenties, a kind and cheerful hospital nurse. Clear, warm, natural "
             "conversational Japanese with a medium pitch, friendly and a little brisk. Native Japanese speaker with "
             "natural standard Tokyo Japanese intonation, speaking Japanese. Not anime-like, not breathy. Studio quality recording.",
        text="空き箱、ぶつけないようにね。今日は申し送りが多いから、先にこっちの確認からいこう。ハコブくん、"
             "三階のナースステーションまでお願いね。ありがとう、助かったよ。次の患者さんの点滴、準備しておくね。それじゃあ、よろしくお願いします。"),
    "寺島": dict(
        desc="A Japanese woman in her mid forties, a seasoned, down-to-earth hospital nurse. Warm, slightly low pitch, "
             "practical and a little blunt but kind, natural everyday Japanese. Native Japanese speaker with "
             "natural standard Tokyo Japanese intonation, speaking Japanese. Not anime-like, not breathy. Studio quality recording.",
        text="この箱、運ぶの手伝って。え、呼んでないよ？ ちょっと、そこ通るよ。まったくもう、慌てなくていいからね。"
             "あとで倉庫の在庫も見ておいてくれる？ 足りないものがあったら、私に言ってちょうだい。じゃあ、お願いね。"),
    "白石": dict(
        desc="A Japanese woman in her late twenties, a serious young robotics engineer. Polite, earnest and a little "
             "nervous, clear medium pitch, natural conversational Japanese. Native Japanese speaker with natural "
             "standard Tokyo Japanese intonation, speaking Japanese. Not anime-like, not breathy. Studio quality recording.",
        text="今月から、ハコブくんの評価指標は配送件数です。えっと、ログを見ると、件数は先月の十倍です。"
             "ただ、ちょっと気になる点もあって。すみません、もう一度データを確認してから、改めてご報告しますね。よろしくお願いします。"),
    "槙原": dict(
        desc="A Japanese man in his late twenties, a calm and polite robotics engineer. Soft, clear, slightly low "
             "medium pitch, careful and earnest, natural conversational Japanese. Native Japanese speaker with natural "
             "standard Tokyo Japanese intonation, speaking Japanese. Not anime-like, not breathy. Studio quality recording.",
        text="センサーの窓だけは、塞がないでください。飾り付けは大歓迎です。ただ、前を見るための窓がここにあるので、"
             "この丸い部分だけは空けておいてもらえると助かります。目印のシールを貼っておきますね。どうぞ、よろしくお願いします。"),
    "田中": dict(
        desc="A Japanese man in his mid thirties, a busy hospital doctor. Brisk, confident, slightly impatient but "
             "kind, medium-low pitch, natural conversational Japanese. Native Japanese speaker with natural standard "
             "Tokyo Japanese intonation, speaking Japanese. Not anime-like, not breathy. Studio quality recording.",
        text="三〇三号室まで、急ぎで頼む。わかったから、早く。確認はもういいよ。次の患者さんが待ってるんだ。"
             "ああ、ごめん、さっきの検査結果、あとで病棟にも回しておいてくれるかな。よろしく。それじゃあ、また夕方の回診で。"),
    "小芝": dict(
        desc="A Japanese man in his mid twenties who has only just become a doctor after residency and has no confidence yet. "
             "Timid, hesitant and unsure of himself, soft and slightly thin voice, speaks a little quietly with small pauses "
             "as if checking whether he is right, light medium-high pitch, gentle and awkward but sincere. Natural conversational "
             "Japanese. Native Japanese speaker with natural standard Tokyo Japanese intonation, speaking Japanese. "
             "Not anime-like, not breathy, not a deep voice. Studio quality recording.",
        text="あの、三〇一号室に、ラシックスを……お願いします。あれ、さっきも言いましたっけ。すみません、えっと、"
             "ちょっと自信がなくて。大城さん、移動可能な経路の中で、という条件定義が……必要だった、かな。次からは、ちゃんと確認します。"),
    "高杉": dict(
        desc="A Japanese woman in her early thirties, a calm and polite hospital nurse. Gentle, clear, medium-high pitch, "
             "natural conversational Japanese. Native Japanese speaker with natural standard Tokyo Japanese intonation, speaking Japanese. Not anime-like, not breathy. Studio quality recording.",
        text="はい、今行きます。こちらのお部屋で大丈夫ですよ。ゆっくりで構いませんからね。何かあったら、"
             "このボタンで呼んでください。夜中でも、すぐに来ますから。お大事になさってください。明日の朝も、また様子を見に来ますね。"),
    "中村": dict(   # 2026-10-24 追加（入院患者の中村拓也さん・第72話・第84話。名前の出ない男性の患者にも使う）
        desc="A Japanese man in his early forties, an inpatient at a hospital. Friendly, relaxed and a little cheeky, "
             "medium-low pitch, casual everyday Japanese, a bit playful. Native Japanese speaker with natural standard "
             "Tokyo Japanese intonation, speaking Japanese. Not anime-like, not breathy. Studio quality recording.",
        text="ちょっと、トイレどこ？ いやあ、置いてかれるかと思ったよ。先生、俺より緊張してません？ "
             "声まね、けっこう自信あるんですよ。プリン、まだかなあ。入院してると、楽しみがそれくらいしかなくてね。よろしく頼みますよ。"),
    "森田茂": dict(   # 2026-10-24 追加（年配の男性。第65話の面会の人。名前の出ない年配の男性にも使う）
        desc="A Japanese man in his late sixties, a gentle and polite retired man visiting a hospital. Soft, slightly hoarse, "
             "low pitch, calm but a little unsure and hesitant, natural conversational Japanese. Native Japanese speaker with "
             "natural standard Tokyo Japanese intonation, speaking Japanese. Not anime-like, not breathy. Studio quality recording.",
        text="すいません、三〇四号室は、どっちですか。家内の見舞いに来たんですが、どうも道がわからなくて。"
             "この病院は広いですなあ。ああ、ありがとうございます。助かりました。エレベーターは、あちらでよろしいですか。お世話になります。"),
    "森田久美子": dict(   # 2026-10-24 追加（年配の女性。第71話の面会の久美子さん）
        desc="A Japanese woman in her mid sixties, a warm, talkative and frank housewife. Medium pitch, lively, a little brisk "
             "like a cheerful neighborhood aunt, natural everyday Japanese. Native Japanese speaker with natural standard "
             "Tokyo Japanese intonation, speaking Japanese. Not anime-like, not breathy. Studio quality recording.",
        text="あら、ロボットさんは？ 速いのよ、あなた。こっちは荷物持ってるんだから。そう、それでいいの。"
             "ゆっくりでいいのよ、ゆっくりで。うちの人の病室、もうすぐかしら。ありがとうね、ほんとに助かったわ。また来週も来るからね。"),
    "ハコブ_イケボ": dict(   # 2026-10-24 追加（第17話。アップデートでなった「超重低音のイケメンボイス」のハコブくん）
        desc="A Japanese man with an extremely deep, rich bass voice, very handsome and theatrical, like a loyal samurai retainer "
             "in a period drama. Slow, dignified, smooth and a little over-the-top, very low pitch. Native Japanese speaker "
             "with natural standard Tokyo Japanese intonation, speaking Japanese. Not anime-like, not breathy. Studio quality recording.",
        text="御意。ただいま参上つかまつった、姫君。お申し付けとあらば、この身が朽ちるまで、どこへなりとも運びましょう。"
             "……御意。姫君の、望むままに。拙者、検体の搬送にかけては、誰にも引けを取りませぬ。いざ、参らん。"),
}

ap = argparse.ArgumentParser()
ap.add_argument("mode", choices=["design", "save"])
ap.add_argument("who")
ap.add_argument("pick", nargs="?", type=int)
ap.add_argument("--seed", type=int, default=7)
ap.add_argument("--model", default="eleven_ttv_v3")   # 2026-10-19: 既定（multilingual_ttv_v2）では日本語の抑揚が不自然だったので v3 にした
a = ap.parse_args()
key = os.environ.get("ELEVENLABS_API_KEY")
if not key:
    sys.exit("ELEVENLABS_API_KEY が入っていません。read -s ELEVENLABS_API_KEY && export ELEVENLABS_API_KEY を先に")
if a.who not in CAST:
    sys.exit(f"CAST にいない名前です: {a.who}（いるのは {'・'.join(CAST)}）")
c = CAST[a.who]
d = os.path.join("声の候補", a.who)
os.makedirs(d, exist_ok=True)

def post(url, body):
    req = urllib.request.Request(url, data=json.dumps(body).encode(), headers={"xi-api-key": key, "Content-Type": "application/json"})
    try:
        with urllib.request.urlopen(req, timeout=180) as r:
            return json.loads(r.read())
    except urllib.error.HTTPError as e:
        sys.exit(f"止まりました: {e.code} {e.read().decode(errors='ignore')[:400]}")

if a.mode == "design":
    r = post("https://api.elevenlabs.io/v1/text-to-voice/design?output_format=mp3_44100_128",
             {"voice_description": c["desc"], "text": c["text"], "seed": a.seed, "model_id": a.model})
    ids = []
    for k, p in enumerate(r["previews"], 1):
        open(os.path.join(d, f"候補{k}.mp3"), "wb").write(base64.b64decode(p["audio_base_64"]))
        ids.append(p["generated_voice_id"])
    json.dump({"seed": a.seed, "ids": ids}, open(os.path.join(d, "候補.json"), "w"), ensure_ascii=False, indent=1)
    print(f"{a.who} の候補を {len(ids)} つ作りました: {d}/候補1.mp3〜  聞いて、気に入った番号で  python3 voice_design.py save {a.who} 番号")
else:
    if not a.pick:
        sys.exit("保存する候補の番号を書いてください（例: python3 voice_design.py save 大城 2）")
    ids = json.load(open(os.path.join(d, "候補.json")))["ids"]
    r = post("https://api.elevenlabs.io/v1/text-to-voice",
             {"voice_name": f"P2M_{a.who}", "voice_description": c["desc"], "generated_voice_id": ids[a.pick - 1]})
    book = json.load(open("声の一覧.json")) if os.path.exists("声の一覧.json") else {}
    book[a.who] = r["voice_id"]
    json.dump(book, open("声の一覧.json", "w"), ensure_ascii=False, indent=1)
    print(f"{a.who} の声を保存しました: {r['voice_id']}（声の一覧.json に書きました）")
```

### _制作ベース/声の一覧.json

保存した自作の声（10個）

```json
{
 "寺島": "yWM6Fym2UxDGZqWRoGLB",
 "高杉": "2zABg81whjrIKypr7XTd",
 "大城": "PrFsT9a2iZebpuiWVeug",
 "白石": "orQ2eWONBvtrTIvdS8RS",
 "槙原": "Sryf5xKmMsQChU3HsLhZ",
 "田中": "ydyMD3g4SbEeNpF0wahP",
 "小芝": "Qs0BJHky1uZDKyp7LL2L",
 "ハコブ_イケボ": "N2Gsbfc4IcGM8KYgkrUz",
 "森田久美子": "xl4Pf62eVCKmATWJprpj"
}
```

### _ショート専用/20話のセリフ.json

20話の107文

```json
{
 "第7話_ピンポン": {
  "田中": [
   "残念ながら、検査の結果が、思わしくなく…。"
  ],
  "ハコブ": [
   "ピンポンパンポーン！ 検便容器を、回収してまいりました！",
   "タスク、完了。"
  ]
 },
 "第9話_外装": {
  "高杉": [
   "ハコブくん、今日もお疲れ様〜。寝てるかな？"
  ],
  "槙原": [
   "あ、外装、外してるだけですよ。"
  ],
  "ハコブ": [
   "タスク、完了。"
  ]
 },
 "第17話_イケボ": {
  "寺島": [
   "連絡事項です。今日から、ハコブくんの音声ガイダンスが更新されました。",
   "メーカーいわく、親しみやすさを重視したアップデートだそうです。"
  ],
  "大城": [
   "ハコブくん、ちょうどいいところに。こっちの検体、運んでくれる？",
   "ハコブくん、こっち来て！",
   "キャラが濃すぎるわ！ 仕事にならないから、前のカタコトに戻して！"
  ],
  "ハコブ_イケボ": [
   "御意。",
   "ただいま参上つかまつった、姫君。",
   "……御意。姫君の、望むままに……。"
  ]
 },
 "第23話_忍者": {
  "大城": [
   "ひっ！",
   "静かすぎて、心臓に悪いわ！ 少しは音を出して！"
  ],
  "ハコブ": [
   "忍者モード、解除します。"
  ]
 },
 "第46話_メニュー": {
  "大城": [
   "ハコブくんの音量、夜勤用に下げておきたい。",
   "…！",
   "もう、トップ画面に「よく使う設定」を、3つだけ出して！"
  ],
  "ハコブ": [
   "同意します。深すぎました。"
  ]
 },
 "第47話_8時間": {
  "寺島": [
   "あ、後でね。"
  ],
  "大城": [
   "あら、なんか今日、変だと思ってた。",
   "ごめん、表示の仕方を変えよう。"
  ],
  "ハコブ": [
   "右車輪の、メンテナンスを推奨します。",
   "8時間前から、告知しておりました。"
  ]
 },
 "第51話_自己診断": {
  "大城": [
   "ハコブくん、目、悪くなった？",
   "私たちが、定期的に拭くわね。"
  ],
  "ハコブ": [
   "自己診断結果、全機能、正常。",
   "視界不良を、検知できませんでした。",
   "自分の目は、自分で見えない。"
  ]
 },
 "第52話_夜勤": {
  "ハコブ": [
   "大城様、今月の夜勤は、過去6カ月平均より、40パーセント多いです。",
   "シフト表を、閲覧しました。",
   "以後、参照する前に、告知します。"
  ],
  "大城": [
   "ありがた…いけど、どこまで見るの？",
   "悪気はないのよね…。でも、線引きしようね。",
   "私のデータ見たら、見たって先に言って。"
  ]
 },
 "第60話_再現しない": {
  "大城": [
   "白石さん、ハコブくんが、たまに急停止するの。週に2、3回。",
   "白石さーん！ 戻ってきて！"
  ],
  "白石": [
   "再現条件、調べに来ました！",
   "…起きないですね。",
   "廊下のワイファイ、切り替えの時の挙動でした…。次回も録画、お願いします！"
  ],
  "ハコブ": [
   "現場の目が、最強のセンサーです。"
  ]
 },
 "第62話_確信度": {
  "大城": [
   "ノルバスクを、三番の保管庫まで、お願い。",
   "なんで最初に言わないの！ 二度手間よ！",
   "その方がいいの。迷ってるなら、迷ってる顔して。"
  ],
  "ハコブ": [
   "了解しました。",
   "確認します。薬剤名を、もう一度。",
   "聞き取れました。確信度、低。"
  ]
 },
 "第63話_通行止め": {
  "ハコブ": [
   "経路、再計算。",
   "経路、再計算。経路、再計算。",
   "掲示は、読めます。地図は、更新されていません。"
  ],
  "高杉": [
   "そこ、昨日から壁なのよ。"
  ]
 },
 "第65話_道案内": {
  "森田茂": [
   "すいません、304号室は、どっちですか。"
  ],
  "ハコブ": [
   "職員認証が、必要です。",
   "権限を、機能ごとに分けます。"
  ],
  "大城": [
   "道案内くらい、誰にでも答えていいと思うの。"
  ]
 },
 "第71話_置いてけぼり": {
  "ハコブ": [
   "こちらです。",
   "経路は、最短です。",
   "合わせます。"
  ],
  "森田久美子": [
   "あら、ロボットさんは？",
   "速いのよ、あなた。こっちは荷物持ってるんだから。",
   "そう、それでいいの。"
  ]
 },
 "第72話_トイレ": {
  "ハコブ": [
   "目的地、405号室。案内中。",
   "経路を、継続します。",
   "経路を、継続します。"
  ],
  "中村": [
   "ちょっと、トイレどこ？",
   "置いてかれるかと思った。"
  ],
  "大城": [
   "案内中でも、寄り道は入るのよ。"
  ],
  "白石": [
   "あ、トイレ…。盲点でした。"
  ]
 },
 "第84話_プリン": {
  "田中": [
   "ハコブくん、プリンを持ってきてくれ。至急だ。",
   "頼んでない。",
   "…俺の声、そんなに似てたか？"
  ],
  "ハコブ": [
   "田中先生の声と、一致。了解しました。",
   "ご注文の、プリンです。",
   "以後、声だけでは、受け付けません。"
  ],
  "中村": [
   "けっこう、自信あります。"
  ]
 },
 "第89話_お手すき": {
  "小芝": [
   "あの…もしお手すきでしたら、ルートのセットを…。あ、急がなくて大丈夫なんですけど…。",
   "お手すきの時でって…言っちゃいました…。",
   "…急ぎです。"
  ],
  "ハコブ": [
   "了解しました。優先度、低で登録します。",
   "以後、言い方ではなく、「急ぎですか」と聞きます。"
  ],
  "大城": [
   "先生、セットは！？",
   "急ぎなら、急ぎって言いなさい！"
  ]
 },
 "第90話_伝言": {
  "小芝": [
   "田中先生に…見間違いかもしれないんですけど…お時間ある時にでも…と、伝えてもらえますか。",
   "は、はい…。"
  ],
  "ハコブ": [
   "小芝先生より、伝言です。要約します。",
   "田中先生の指示に、誤りがあります。",
   "伝言は、要約せずに届けます。"
  ],
  "田中": [
   "確かに、間違ってた。助かったよ。…次は、直接言いに来い。"
  ]
 },
 "第91話_心拍数": {
  "小芝": [
   "チクッとしますね〜。",
   "し、してません！"
  ],
  "ハコブ": [
   "小芝先生の心拍数、平常時の1.8倍です。",
   "以後、本人にだけ伝えます。"
  ],
  "中村": [
   "先生、俺より緊張してません？"
  ],
  "大城": [
   "そういうのは、本人にだけ、こっそり教えてあげて。"
  ]
 },
 "第92話_なかったことに": {
  "小芝": [
   "ハコブくん…さっきの、なかったことに、できない？"
  ],
  "ハコブ": [
   "記録は、消せません。",
   "ですが、報告は、一緒にできます。"
  ],
  "大城": [
   "言いに来てくれて、助かるわ。怒らないから、次も言いに来てね。"
  ]
 },
 "第98話_起こさない": {
  "小芝": [
   "朝まで、絶対に起こさないでね。",
   "そういう時は、起こしてよ！",
   "…絶対は、取り消します。"
  ],
  "ハコブ": [
   "了解しました。",
   "おはようございます。先生の承認待ちが、3件ありました。",
   "「絶対に」と、言われました。",
   "起こすべき時は、起こします。"
  ]
 }
}
```

### _ショート専用/20話の声.sh

20話の声をまとめて作る。中村→槙原、森田茂→田中の声を借りる行を足した

```bash
#!/bin/bash
# 20話ぶん（2026-10-24）の声をまとめて作る。先に read -s ELEVENLABS_API_KEY && export ELEVENLABS_API_KEY
# 新しい声（中村・森田茂・森田久美子・ハコブ_イケボ）は、先に _制作ベース で voice_design.py design → save をすませる。
# まだ保存していない声の行は「とばしました」と出して先へ進む（あとでもう一度動かせば、できていない分だけ作る）
cd "$(dirname "$0")"
B=../_制作ベース
HK="gWJpc8whMBqnF7Y0kc9f"
id() { python3 -c "import json;print(json.load(open('../$B/声の一覧.json')).get('$1',''))"; }
mk() { # mk 人 本数 文
  local who="$1" n="$2" txt="$3" v
  if [ "$who" = "ハコブ" ]; then v=$HK
  elif [ "$who" = "中村" ]; then v="$(id 槙原)"      # 2026-10-24 自作の声が上限（10/10）のため、中村さんは槙原さんの声を借りる
  elif [ "$who" = "森田茂" ]; then v="$(id 田中)"    # 同じく、森田茂さんは田中先生の声を借りる（どちらも同じ話には出ない）
  else v="$(id "$who")"; fi
  if [ -z "$v" ]; then echo "  ！ $who の声がまだ保存されていないので、とばしました"; SKIP=1; return; fi
  [ -f "voice_eleven/$who/試し_$(printf %02d $n).mp3" ] && return
  python3 ../$B/tts_eleven.py --voice "$v" --name "$who" --try "$txt" || { echo "  ！ $who で止まりました"; SKIP=1; }
}
SKIP=0
echo "== 第7話_ピンポン"; mkdir -p "第7話_ピンポン"; cd "第7話_ピンポン"
[ -f narration.py ] || printf "# ショート専用。声は --try で作る（_ショート専用/20話の声.sh）\nSEGS = []\n" > narration.py
mk "田中" 1 "残念ながら、検査の結果が、思わしくなく…。"
mk "ハコブ" 2 "ピンポンパンポーン！ 検便容器を、回収してまいりました！｜タスク、完了。"
cd ..
echo "== 第9話_外装"; mkdir -p "第9話_外装"; cd "第9話_外装"
[ -f narration.py ] || printf "# ショート専用。声は --try で作る（_ショート専用/20話の声.sh）\nSEGS = []\n" > narration.py
mk "高杉" 1 "ハコブくん、今日もお疲れ様〜。寝てるかな？"
mk "槙原" 1 "あ、外装、外してるだけですよ。"
mk "ハコブ" 1 "タスク、完了。"
cd ..
echo "== 第17話_イケボ"; mkdir -p "第17話_イケボ"; cd "第17話_イケボ"
[ -f narration.py ] || printf "# ショート専用。声は --try で作る（_ショート専用/20話の声.sh）\nSEGS = []\n" > narration.py
mk "寺島" 2 "連絡事項です。今日から、ハコブくんの音声ガイダンスが更新されました。｜メーカーいわく、親しみやすさを重視したアップデートだそうです。"
mk "大城" 3 "ハコブくん、ちょうどいいところに。こっちの検体、運んでくれる？｜ハコブくん、こっち来て！｜キャラが濃すぎるわ！ 仕事にならないから、前のカタコトに戻して！"
mk "ハコブ_イケボ" 3 "御意。｜ただいま参上つかまつった、姫君。｜……御意。姫君の、望むままに……。"
cd ..
echo "== 第23話_忍者"; mkdir -p "第23話_忍者"; cd "第23話_忍者"
[ -f narration.py ] || printf "# ショート専用。声は --try で作る（_ショート専用/20話の声.sh）\nSEGS = []\n" > narration.py
mk "大城" 2 "ひっ！｜静かすぎて、心臓に悪いわ！ 少しは音を出して！"
mk "ハコブ" 1 "忍者モード、解除します。"
cd ..
echo "== 第46話_メニュー"; mkdir -p "第46話_メニュー"; cd "第46話_メニュー"
[ -f narration.py ] || printf "# ショート専用。声は --try で作る（_ショート専用/20話の声.sh）\nSEGS = []\n" > narration.py
mk "大城" 3 "ハコブくんの音量、夜勤用に下げておきたい。｜…！｜もう、トップ画面に「よく使う設定」を、3つだけ出して！"
mk "ハコブ" 1 "同意します。深すぎました。"
cd ..
echo "== 第47話_8時間"; mkdir -p "第47話_8時間"; cd "第47話_8時間"
[ -f narration.py ] || printf "# ショート専用。声は --try で作る（_ショート専用/20話の声.sh）\nSEGS = []\n" > narration.py
mk "寺島" 1 "あ、後でね。"
mk "大城" 2 "あら、なんか今日、変だと思ってた。｜ごめん、表示の仕方を変えよう。"
mk "ハコブ" 2 "右車輪の、メンテナンスを推奨します。｜8時間前から、告知しておりました。"
cd ..
echo "== 第51話_自己診断"; mkdir -p "第51話_自己診断"; cd "第51話_自己診断"
[ -f narration.py ] || printf "# ショート専用。声は --try で作る（_ショート専用/20話の声.sh）\nSEGS = []\n" > narration.py
mk "大城" 2 "ハコブくん、目、悪くなった？｜私たちが、定期的に拭くわね。"
mk "ハコブ" 3 "自己診断結果、全機能、正常。｜視界不良を、検知できませんでした。｜自分の目は、自分で見えない。"
cd ..
echo "== 第52話_夜勤"; mkdir -p "第52話_夜勤"; cd "第52話_夜勤"
[ -f narration.py ] || printf "# ショート専用。声は --try で作る（_ショート専用/20話の声.sh）\nSEGS = []\n" > narration.py
mk "ハコブ" 3 "大城様、今月の夜勤は、過去6カ月平均より、40パーセント多いです。｜シフト表を、閲覧しました。｜以後、参照する前に、告知します。"
mk "大城" 3 "ありがた…いけど、どこまで見るの？｜悪気はないのよね…。でも、線引きしようね。｜私のデータ見たら、見たって先に言って。"
cd ..
echo "== 第60話_再現しない"; mkdir -p "第60話_再現しない"; cd "第60話_再現しない"
[ -f narration.py ] || printf "# ショート専用。声は --try で作る（_ショート専用/20話の声.sh）\nSEGS = []\n" > narration.py
mk "大城" 2 "白石さん、ハコブくんが、たまに急停止するの。週に2、3回。｜白石さーん！ 戻ってきて！"
mk "白石" 3 "再現条件、調べに来ました！｜…起きないですね。｜廊下のワイファイ、切り替えの時の挙動でした…。次回も録画、お願いします！"
mk "ハコブ" 1 "現場の目が、最強のセンサーです。"
cd ..
echo "== 第62話_確信度"; mkdir -p "第62話_確信度"; cd "第62話_確信度"
[ -f narration.py ] || printf "# ショート専用。声は --try で作る（_ショート専用/20話の声.sh）\nSEGS = []\n" > narration.py
mk "大城" 3 "ノルバスクを、三番の保管庫まで、お願い。｜なんで最初に言わないの！ 二度手間よ！｜その方がいいの。迷ってるなら、迷ってる顔して。"
mk "ハコブ" 3 "了解しました。｜確認します。薬剤名を、もう一度。｜聞き取れました。確信度、低。"
cd ..
echo "== 第63話_通行止め"; mkdir -p "第63話_通行止め"; cd "第63話_通行止め"
[ -f narration.py ] || printf "# ショート専用。声は --try で作る（_ショート専用/20話の声.sh）\nSEGS = []\n" > narration.py
mk "ハコブ" 3 "経路、再計算。｜経路、再計算。経路、再計算。｜掲示は、読めます。地図は、更新されていません。"
mk "高杉" 1 "そこ、昨日から壁なのよ。"
cd ..
echo "== 第65話_道案内"; mkdir -p "第65話_道案内"; cd "第65話_道案内"
[ -f narration.py ] || printf "# ショート専用。声は --try で作る（_ショート専用/20話の声.sh）\nSEGS = []\n" > narration.py
mk "森田茂" 1 "すいません、304号室は、どっちですか。"
mk "ハコブ" 2 "職員認証が、必要です。｜権限を、機能ごとに分けます。"
mk "大城" 1 "道案内くらい、誰にでも答えていいと思うの。"
cd ..
echo "== 第71話_置いてけぼり"; mkdir -p "第71話_置いてけぼり"; cd "第71話_置いてけぼり"
[ -f narration.py ] || printf "# ショート専用。声は --try で作る（_ショート専用/20話の声.sh）\nSEGS = []\n" > narration.py
mk "ハコブ" 3 "こちらです。｜経路は、最短です。｜合わせます。"
mk "森田久美子" 3 "あら、ロボットさんは？｜速いのよ、あなた。こっちは荷物持ってるんだから。｜そう、それでいいの。"
cd ..
echo "== 第72話_トイレ"; mkdir -p "第72話_トイレ"; cd "第72話_トイレ"
[ -f narration.py ] || printf "# ショート専用。声は --try で作る（_ショート専用/20話の声.sh）\nSEGS = []\n" > narration.py
mk "ハコブ" 3 "目的地、405号室。案内中。｜経路を、継続します。｜経路を、継続します。"
mk "中村" 2 "ちょっと、トイレどこ？｜置いてかれるかと思った。"
mk "大城" 1 "案内中でも、寄り道は入るのよ。"
mk "白石" 1 "あ、トイレ…。盲点でした。"
cd ..
echo "== 第84話_プリン"; mkdir -p "第84話_プリン"; cd "第84話_プリン"
[ -f narration.py ] || printf "# ショート専用。声は --try で作る（_ショート専用/20話の声.sh）\nSEGS = []\n" > narration.py
mk "田中" 3 "ハコブくん、プリンを持ってきてくれ。至急だ。｜頼んでない。｜…俺の声、そんなに似てたか？"
mk "ハコブ" 3 "田中先生の声と、一致。了解しました。｜ご注文の、プリンです。｜以後、声だけでは、受け付けません。"
mk "中村" 1 "けっこう、自信あります。"
cd ..
echo "== 第89話_お手すき"; mkdir -p "第89話_お手すき"; cd "第89話_お手すき"
[ -f narration.py ] || printf "# ショート専用。声は --try で作る（_ショート専用/20話の声.sh）\nSEGS = []\n" > narration.py
mk "小芝" 3 "あの…もしお手すきでしたら、ルートのセットを…。あ、急がなくて大丈夫なんですけど…。｜お手すきの時でって…言っちゃいました…。｜…急ぎです。"
mk "ハコブ" 2 "了解しました。優先度、低で登録します。｜以後、言い方ではなく、「急ぎですか」と聞きます。"
mk "大城" 2 "先生、セットは！？｜急ぎなら、急ぎって言いなさい！"
cd ..
echo "== 第90話_伝言"; mkdir -p "第90話_伝言"; cd "第90話_伝言"
[ -f narration.py ] || printf "# ショート専用。声は --try で作る（_ショート専用/20話の声.sh）\nSEGS = []\n" > narration.py
mk "小芝" 2 "田中先生に…見間違いかもしれないんですけど…お時間ある時にでも…と、伝えてもらえますか。｜は、はい…。"
mk "ハコブ" 3 "小芝先生より、伝言です。要約します。｜田中先生の指示に、誤りがあります。｜伝言は、要約せずに届けます。"
mk "田中" 1 "確かに、間違ってた。助かったよ。…次は、直接言いに来い。"
cd ..
echo "== 第91話_心拍数"; mkdir -p "第91話_心拍数"; cd "第91話_心拍数"
[ -f narration.py ] || printf "# ショート専用。声は --try で作る（_ショート専用/20話の声.sh）\nSEGS = []\n" > narration.py
mk "小芝" 2 "チクッとしますね〜。｜し、してません！"
mk "ハコブ" 2 "小芝先生の心拍数、平常時の1.8倍です。｜以後、本人にだけ伝えます。"
mk "中村" 1 "先生、俺より緊張してません？"
mk "大城" 1 "そういうのは、本人にだけ、こっそり教えてあげて。"
cd ..
echo "== 第92話_なかったことに"; mkdir -p "第92話_なかったことに"; cd "第92話_なかったことに"
[ -f narration.py ] || printf "# ショート専用。声は --try で作る（_ショート専用/20話の声.sh）\nSEGS = []\n" > narration.py
mk "小芝" 1 "ハコブくん…さっきの、なかったことに、できない？"
mk "ハコブ" 2 "記録は、消せません。｜ですが、報告は、一緒にできます。"
mk "大城" 1 "言いに来てくれて、助かるわ。怒らないから、次も言いに来てね。"
cd ..
echo "== 第98話_起こさない"; mkdir -p "第98話_起こさない"; cd "第98話_起こさない"
[ -f narration.py ] || printf "# ショート専用。声は --try で作る（_ショート専用/20話の声.sh）\nSEGS = []\n" > narration.py
mk "小芝" 3 "朝まで、絶対に起こさないでね。｜そういう時は、起こしてよ！｜…絶対は、取り消します。"
mk "ハコブ" 4 "了解しました。｜おはようございます。先生の承認待ちが、3件ありました。｜「絶対に」と、言われました。｜起こすべき時は、起こします。"
cd ..
[ $SKIP = 0 ] && echo できあがり || echo "とばした声があります。声を保存してから、もう一度 bash 20話の声.sh"
```

### _ショート専用/背景/背景のプロンプト.md

ChatGPT に背景を頼む文。新しい8話と使い回しの対応を足した

```markdown
# マンガショートの背景（話ごと）

## 使い方
1. ChatGPT に `_制作ベース/hakobu/bg_empty.png`（今の空の病室）を添付する
2. 「共通」の文に、話ごとの「場所」の文をつなげて送る
3. できた画像を、このフォルダに `第NN話.png` の名前で保存する（例: 第81話.png）

## 共通
添付した病室の画像と、カメラの高さ・画角・床の位置をそろえて、場所だけを下の場所に変えた背景画像を作ってください。
- 人物とロボットは描かない
- 画面の中央からやや右の床に、配膳ロボット1台が立てる何もないスペースを空ける（添付画像でロボットが立っていた位置）
- 床は画面の下3分の1くらいに見える。カメラは人の腰くらいの高さで、正面から少し見下ろす
- 写真のような質感、やわらかい光
- 縦長 4:5
- 文字・看板の字・ロゴは入れない

## 場所（話ごと）
- 第81話（かぼちゃ）: 小児病棟のプレイルーム。壁にハロウィンの飾り（紙のコウモリ、かぼちゃのガーランド、オレンジと紫の飾り）。明るい昼
- 第85話（取り消し）: 病棟の長い廊下。両側に手すり、奥行きが見える。奥にリネンを積んだカート
- 第82話（ため息）: 夕方のナースステーションの前。カウンター、モニター、書類の束。少し疲れた雰囲気の暖色の照明
- 第20話（撫でて）: 窓から日が差す明るい病棟の廊下。手すり、床に窓の光
- 第88話（ついてきて）: 夜の屋外のバス停。病院の外の歩道、街灯、ベンチ、時刻表の標識（字は入れない）
- 第83話（確認）: 病棟のエレベーターホール。エレベーターの扉、案内表示（字は入れない）
- 第87話（小声）: 消灯後の夜の病室。ベッドのまわりのカーテンは閉まっていて患者は見えない。窓から青い月明かり、足もとに常夜灯
- 第43話（謝罪）: 病棟の物品・検体の保管室。棚に箱、採血管のトレー、蛍光灯

## 場所（2026-10-20 追加の10話）
- 第24話（シュレッダー）: 昼のナースステーションの中。書類やメモが散らかった事務机と、その横に小さな業務用シュレッダー（紙の字は読めないぼかし）
- 第22話（消毒）: 明るい昼の病棟の廊下。壁に手指消毒剤のボトルの台
- 第86話（手招き）: 病棟のまっすぐな長い廊下。奥行きがよく見える。明るい昼
- 第19話（前髪）: 病院の薬剤部の受け渡しカウンターの前。奥に薬品棚
- 第48話（省電力）: 夕方の病棟の廊下のすみ。照明が少し落ちて、窓から夕日
- 第29話（ハワイ）: 深夜のナースステーション。照明は落としてあり、モニターの光が青白く当たる
- 第41話（公平）: 病棟の廊下の、左右に分かれる分かれ道（T字路）。明るい昼
- 第67話（譲り合い）: 病棟の狭い廊下の曲がり角（壁の角が手前に見える）。角の奥に配膳カート
- 第21話（幽霊）: 夜の病棟の廊下。壁に人物のポスターが1枚貼ってある（人物はぼんやり・字は入れない）。足もとに常夜灯
- 第18話（最短）: 病院の階段の前。上りの階段が見える。手すり。昼

## 場所（2026-10-24 追加の20話）

### 新しく作る（8枚）
- 第7話（ピンポン）: 病院の小さな面談室。白い壁、小さな丸テーブルと椅子が2脚、壁ぎわに診察用の書類棚。落ち着いた昼の光
- 第9話（外装）: 病院の機材室。金属の棚に工具箱・ケーブル・部品の箱、作業台、床に工具。蛍光灯の白い光
- 第52話（夜勤）: 病院のスタッフ休憩室。2人がけのソファ、ローテーブルにマグカップ、小さな冷蔵庫と電気ポット。午後のやわらかい光
- 第63話（通行止め）: 病棟の廊下。奥の通路をふさぐように、工事用の白いパーテーション（黄色と黒のしまの帯つき）が並んで立っている。貼り紙は白紙で字は入れない。明るい昼
- 第72話（トイレ）: 病院1階の入院受付のそばの広い廊下。壁ぎわに待合のベンチ、奥にエレベーターの扉。明るい昼
- 第90話（伝言）: 病院の医局（医師の控室）。机が並び、パソコンのモニター、白衣を掛けたハンガーラック、本棚。夕方の光
- 第91話（心拍数）: 病院の採血室（処置室）。採血用の台と患者用の椅子、壁ぎわに注射器や採血管のトレーが並ぶ棚、手洗いの流し。明るく清潔な白い光
- 第98話（起こさない）: 夜明け前の病棟の廊下の、当直室の扉の前。扉は閉まっていて、扉の横の札は白紙。窓の外が少し白みはじめた青い光、足もとに常夜灯

### 前の絵を使い回す（作らなくてよい）
- 第17話（イケボ）→ 第24話.png（昼のナースステーション）
- 第23話（忍者）→ 第67話.png（廊下の曲がり角）
- 第46話（メニュー）→ 第82話.png（夕方のナースステーション）
- 第47話（8時間）→ 第48話.png（夕方の廊下のすみ）
- 第51話（自己診断）→ 第41話.png（廊下の分かれ道）
- 第60話（再現しない）→ 第83話.png（エレベーターホール）
- 第62話（確信度）→ 第19話.png（薬剤部のカウンター・薬品棚）
- 第65話（道案内）→ 第22話.png（明るい昼の廊下）
- 第71話（置いてけぼり）→ 第86話.png（まっすぐな長い廊下）
- 第84話（プリン）→ _制作ベース/hakobu/bg_empty.png（元の病室）
- 第89話（お手すき）→ 第85話.png（リネンのカートがある長い廊下）
- 第92話（なかったことに）→ 第20話.png（日が差す廊下）
```

### _制作ベース/youtube_schedule.py

Mac が1時間ごとに YouTube の一覧を書き出す。manga_ep を拾う1行を足した

```python
#!/usr/bin/env python3
"""youtube_schedule.py — 自分のチャンネルの動画を、公開予約中のものまで含めて書き出す.

YouTube の公式の仕組み（YouTube Data API v3）で読む。読むだけで、動画には何も書き込まない。
Python の標準の道具だけで動く（pip で入れるものはない）。

許可の鍵は Mac の中だけに置く（Claude には渡さない。ElevenLabs の鍵と同じ考え）:
  ~/.config/pixelstomotors/client_secret.json   Google Cloud で作った「デスクトップ アプリ」の OAuth クライアント
  ~/.config/pixelstomotors/youtube_token.json   ログインの許可（このスクリプトが作る）

書き出すもの（鍵は入らない）:
  このファイルと同じフォルダの youtube_videos.json
  → status_scan.py が読み、進み具合のページの「予約済み」に使う

使い方（Mac のターミナルで）:
  python3 youtube_schedule.py --login     最初の1回。ブラウザで許可して、一覧を書き出す
  python3 youtube_schedule.py             一覧を書き出し直す
  python3 youtube_schedule.py --install   1時間ごとに自動で書き出すように登録する（launchd・毎時5分）
2026-10-16: 自動の登録を StartInterval（3600秒ごと）から StartCalendarInterval（毎時5分）に替えた。
  StartInterval は Mac が眠っている間は時間を数えないため、6:51 のあと 8:29 まで一度も書き出されなかった。
  StartCalendarInterval は、眠っていて時刻を過ぎたときは、起きたときに1回動く。ログの行に時刻も付けた
  python3 youtube_schedule.py --uninstall 自動の登録を外す
"""
import argparse, base64, hashlib, http.server, json, os, re, secrets, subprocess, sys, threading
import urllib.parse, urllib.request, webbrowser
from datetime import datetime, timezone, timedelta

HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "youtube_videos.json")
CONF = os.path.expanduser("~/.config/pixelstomotors")
SECRET = os.path.join(CONF, "client_secret.json")
TOKEN = os.path.join(CONF, "youtube_token.json")
LOG = os.path.join(CONF, "youtube_schedule.log")
SCOPE = "openid email https://www.googleapis.com/auth/youtube.readonly"
API = "https://www.googleapis.com/youtube/v3"
LABEL = "jp.adaptdesign.pixelstomotors.youtube-schedule"
PLIST = os.path.expanduser(f"~/Library/LaunchAgents/{LABEL}.plist")
JST = timezone(timedelta(hours=9))
CHANNEL = "Pixels to Motors"
CHANNEL_ID = "UC3vr_5p9kJch9zCMb44_rsQ"   # @PixelstoMotors   # このチャンネル以外を許可したときは書き出さない


def call(url, data=None, headers=None):
    body = urllib.parse.urlencode(data).encode() if isinstance(data, dict) else data
    req = urllib.request.Request(url, data=body, headers=headers or {})
    try:
        with urllib.request.urlopen(req, timeout=30) as r:
            return json.loads(r.read().decode())
    except urllib.error.HTTPError as e:
        raise SystemExit(f"YouTube / Google から断られました（{e.code}）: {e.read().decode(errors='ignore')[:400]}")


def client():
    if not os.path.isfile(SECRET):
        raise SystemExit(f"{SECRET} がありません。Google Cloud でダウンロードした JSON を、この名前で置いてください")
    c = json.load(open(SECRET))
    c = c.get("installed") or c.get("web") or c
    return c["client_id"], c["client_secret"]


def save_token(t):
    os.makedirs(CONF, exist_ok=True)
    with open(TOKEN, "w") as f:
        json.dump(t, f)
    os.chmod(TOKEN, 0o600)


def login():
    """ブラウザで許可をもらう（この Mac の中だけで受け取る）"""
    cid, csec = client()
    verifier = secrets.token_urlsafe(64)
    challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
    got = {}

    class H(http.server.BaseHTTPRequestHandler):
        def do_GET(self):
            q = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
            got.update({k: v[0] for k, v in q.items()})
            self.send_response(200)
            self.send_header("Content-Type", "text/html; charset=utf-8")
            self.end_headers()
            msg = "許可を受け取りました。この画面は閉じてください。" if "code" in got else "許可されませんでした。"
            self.wfile.write(f"<p style='font:16px sans-serif'>{msg}</p>".encode())

        def log_message(self, *a):
            pass

    srv = http.server.HTTPServer(("127.0.0.1", 0), H)
    redirect = f"http://127.0.0.1:{srv.server_port}/"
    url = "https://accounts.google.com/o/oauth2/v2/auth?" + urllib.parse.urlencode({
        "client_id": cid, "redirect_uri": redirect, "response_type": "code", "scope": SCOPE,
        "access_type": "offline", "prompt": "select_account consent",   # 毎回アカウントを選ばせる
        "code_challenge": challenge, "code_challenge_method": "S256",
    })
    print("ブラウザで Google の許可の画面を開きます。チャンネル「Pixels to Motors」のアカウントを選んで許可してください。")
    print("開かないときは、このアドレスをブラウザに貼ってください:\n" + url)
    webbrowser.open(url)
    th = threading.Thread(target=srv.handle_request)
    th.start(); th.join(timeout=300)
    srv.server_close()
    if "code" not in got:
        raise SystemExit("許可を受け取れませんでした（5分で止めました）。もう一度 --login から")
    t = call("https://oauth2.googleapis.com/token", {
        "code": got["code"], "client_id": cid, "client_secret": csec, "redirect_uri": redirect,
        "grant_type": "authorization_code", "code_verifier": verifier,
    })
    if "refresh_token" not in t:
        raise SystemExit("許可の鍵（refresh_token）が返りませんでした。--login をもう一度")
    email = ""
    try:   # どのアカウントで許可したかを表示する（id_token の中のメールアドレス）
        part = t.get("id_token", "").split(".")[1]
        email = json.loads(base64.urlsafe_b64decode(part + "=" * (-len(part) % 4))).get("email", "")
    except Exception:
        pass
    save_token({"refresh_token": t["refresh_token"], "email": email})
    print("許可を保存しました:", TOKEN)
    print("許可したアカウント:", email or "（分からない）")


def access_token():
    if not os.path.isfile(TOKEN):
        raise SystemExit("まだ許可がありません。python3 youtube_schedule.py --login を先に")
    cid, csec = client()
    rt = json.load(open(TOKEN))["refresh_token"]
    t = call("https://oauth2.googleapis.com/token", {
        "refresh_token": rt, "client_id": cid, "client_secret": csec, "grant_type": "refresh_token"})
    return t["access_token"]


def seconds(iso):
    m = re.match(r"P(?:(\d+)D)?T?(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?", iso or "")
    if not m:
        return None
    d, h, mi, s = (int(x or 0) for x in m.groups())
    return ((d * 24 + h) * 60 + mi) * 60 + s


ID_RE = re.compile(r"(?:youtu\.be/|watch\?v=|shorts/)([A-Za-z0-9_-]{11})")


def fetch():
    tok = access_token()
    hd = {"Authorization": "Bearer " + tok}
    # チャンネルは ID で決め打ちする（許可したアカウントの「自分のチャンネル」が別のこともあるため）
    me = call(f"{API}/channels?part=snippet&mine=true", headers=hd)
    me_names = [i["snippet"]["title"] for i in me.get("items", [])]
    ch = call(f"{API}/channels?part=contentDetails,snippet&id={CHANNEL_ID}", headers=hd)
    if not ch.get("items"):
        raise SystemExit("チャンネル Pixels to Motors が見つかりませんでした")
    owner = any(CHANNEL in n for n in me_names)
    print("許可したアカウント:", json.load(open(TOKEN)).get("email") or "（前の許可。--login し直すと表示されます）")
    print("許可したアカウントのチャンネル:", "・".join(me_names) or "（なし）")
    uploads = ch["items"][0]["contentDetails"]["relatedPlaylists"]["uploads"]
    ids, page = [], ""
    while True:
        r = call(f"{API}/playlistItems?part=contentDetails&maxResults=50&playlistId={uploads}" + (f"&pageToken={page}" if page else ""), headers=hd)
        ids += [i["contentDetails"]["videoId"] for i in r.get("items", [])]
        page = r.get("nextPageToken")
        if not page or len(ids) >= 500:
            break
    out = []
    for k in range(0, len(ids), 50):
        r = call(f"{API}/videos?part=snippet,status,contentDetails&id={','.join(ids[k:k + 50])}", headers=hd)
        for v in r.get("items", []):
            st, sn = v["status"], v["snippet"]
            sec = seconds(v["contentDetails"].get("duration"))
            parent = next((m.group(1) for m in ID_RE.finditer(sn.get("description", "")) if m.group(1) != v["id"]), "")
            out.append({
                "id": v["id"],
                "title": sn.get("title", ""),
                "privacy": st.get("privacyStatus"),            # public / private / unlisted
                "publishAt": st.get("publishAt"),             # 公開予約の日時（予約中だけ）
                "published": sn.get("publishedAt") if st.get("privacyStatus") == "public" else None,
                "seconds": sec,
                "is_short": sec is not None and sec <= 180,   # 3分以内をショートとみなす
                "parent": parent,                             # 説明文にある本編の ID（ショート用）
                "manga_ep": (lambda m: int(m.group(1)) if m else None)(re.search(r"/manga/ep(\d+)", sn.get("description", ""))),
                                                              # 説明文にあるマンガの話の番号（ショート専用のマンガのショート。2026-10-24・SNS の予約に使う）
                "url": f"https://www.youtube.com/watch?v={v['id']}",
            })
    n_priv = sum(1 for v in out if v["privacy"] != "public")
    if not owner and n_priv == 0:
        print("注意: 許可したアカウントが Pixels to Motors の持ち主ではないので、公開予約中の動画は読めていません。--login で持ち主のアカウント（とチャンネル）を選び直してください")
    data = {"作成": datetime.now(JST).strftime("%Y-%m-%d %H:%M"), "チャンネル": ch["items"][0]["snippet"]["title"],
            "予約まで読めた": bool(owner or n_priv), "動画": out}
    with open(OUT, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=1)
    n_s = sum(1 for v in out if v["publishAt"] and v["privacy"] != "public")
    print(f"{data['作成']} {len(out)} 本を書き出しました（うち公開予約 {n_s} 本）: {OUT}")


def install():
    os.makedirs(os.path.dirname(PLIST), exist_ok=True)
    os.makedirs(CONF, exist_ok=True)
    plist = f"""<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
  <key>Label</key><string>{LABEL}</string>
  <key>ProgramArguments</key><array><string>{sys.executable}</string><string>{os.path.abspath(__file__)}</string></array>
  <key>StartCalendarInterval</key><dict><key>Minute</key><integer>5</integer></dict>
  <key>RunAtLoad</key><true/>
  <key>StandardOutPath</key><string>{LOG}</string>
  <key>StandardErrorPath</key><string>{LOG}</string>
</dict></plist>
"""
    with open(PLIST, "w") as f:
        f.write(plist)
    subprocess.run(["launchctl", "unload", PLIST], capture_output=True)
    subprocess.run(["launchctl", "load", PLIST], check=True)
    print("1時間ごとに書き出すように登録しました。うまく動いているかは次のファイルで分かります:", LOG)


def uninstall():
    subprocess.run(["launchctl", "unload", PLIST], capture_output=True)
    if os.path.exists(PLIST):
        os.remove(PLIST)
    print("自動の登録を外しました")


if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("--login", action="store_true")
    ap.add_argument("--install", action="store_true")
    ap.add_argument("--uninstall", action="store_true")
    a = ap.parse_args()
    if a.uninstall:
        uninstall()
    elif a.install:
        install()
    else:
        if a.login:
            login()
        fetch()
```

### KIOXIA _ショート専用/公開予約の表_20261024.txt

```text
ショート専用20本の YouTube 公開予約（平日12:30。祝日の11/3・11/23は空ける）
YouTube Studio でアップロードするとき、この日時で「スケジュールを設定」する。
X の投稿は、公開予約を入れれば自動で同じ時刻に予約される（r2r2r-content の tools/sync_youtube_shorts.py）。
説明文の「マンガの全話」の URL（manga/epNN）で結びつけるので、説明文はそのまま貼ること。

10/26（月）12:30	第7話_ピンポン	深刻な告知の最中に「ピンポンパンポーン！検便容器を回収しました！」と叫ぶロボット #フィジカルai #ロボット
10/27（火）12:30	第9話_外装	外装を外したら、中から光る目がギョロッ。かわいいロボットの中身 #フィジカルai #ロボット
10/28（水）12:30	第17話_イケボ	アップデートで「御意。ただいま参上つかまつった、姫君」と言い出したロボット #フィジカルai #ロボット
10/29（木）12:30	第23話_忍者	静音モードにしたら、気配ゼロで背後にいる忍者みたいなロボット #フィジカルai #ロボット
10/30（金）12:30	第46話_メニュー	音量を下げたいだけなのに、設定がどこまでも深いロボット #フィジカルai #ロボット
11/2（月）12:30	第47話_8時間	8時間ずっと「メンテナンス推奨」と表示していたのに、誰も気づかなかったロボット #フィジカルai #ロボット
11/4（水）12:30	第51話_自己診断	目が悪くなったのに、自己診断は「全機能、正常」と言い張るロボット #フィジカルai #ロボット
11/5（木）12:30	第52話_夜勤	気をきかせて「今月の夜勤、40%多いです」。シフト表を見ていたロボット #フィジカルai #ロボット
11/6（金）12:30	第60話_再現しない	エンジニアが見張っている間は、絶対にバグが起きないロボット #フィジカルai #ロボット
11/9（月）12:30	第62話_確信度	「了解しました！」と即答。でも本当は自信62%だったロボット #フィジカルai #ロボット
11/10（火）12:30	第63話_通行止め	昨日からできた壁の前で「経路、再計算」をくり返すロボット #フィジカルai #ロボット
11/11（水）12:30	第65話_道案内	道を聞いただけなのに「職員認証が必要です」と答えるロボット #フィジカルai #ロボット
11/12（木）12:30	第71話_置いてけぼり	「こちらです」と案内したら、振り返ると誰もついてきていないロボット #フィジカルai #ロボット
11/13（金）12:30	第72話_トイレ	「トイレどこ？」と聞いても「経路を継続します」しか言わないロボット #フィジカルai #ロボット
11/16（月）12:30	第84話_プリン	先生の声まねで「プリン持ってきて」。本物の先生に届けてしまったロボット #フィジカルai #ロボット
11/17（火）12:30	第89話_お手すき	「お手すきの時で…」と頼まれて、本当に後回しにしたロボット #フィジカルai #ロボット
11/18（水）12:30	第90話_伝言	遠慮がちな伝言を要約したら「指示に誤りがあります」と断定してしまったロボット #フィジカルai #ロボット
11/19（木）12:30	第91話_心拍数	笑顔で隠した緊張を「心拍数、平常時の1.8倍です」と発表してしまうロボット #フィジカルai #ロボット
11/20（金）12:30	第92話_なかったことに	「さっきの、なかったことにできない？」と頼まれて、きっぱり断るロボット #フィジカルai #ロボット
11/24（火）12:30	第98話_起こさない	「朝まで絶対に起こさないで」を、本当に一晩守りきったロボット #フィジカルai #ロボット
```

### KIOXIA _ショート専用/第◯話_◯◯/ショート_タイトルと説明文.txt（20話）

```text
=== 第7話_ピンポン/ショート_タイトルと説明文.txt
■ 公開予約（YouTube Studio で設定）
10/26（月）12:30

■ タイトル
深刻な告知の最中に「ピンポンパンポーン！検便容器を回収しました！」と叫ぶロボット #フィジカルai #ロボット

■ 説明文
医師が患者さんのご家族に、検査の結果を伝えている最中。
病院の配送ロボット・ハコブくんが、元気いっぱいに「検便容器を、回収してまいりました！」とアナウンスしてしまいました。

言っている中身は正しくても、場の空気しだいで最悪の一言になる。音声で知らせる機械の「言うタイミング」を描いた、4コママンガ『ハコブくん』第7話です。

マンガの全話: https://pixelstomotors.com/manga/ep07
ほかの動画: https://www.youtube.com/@PixelstoMotors

4コママンガ『ハコブくん』第7話より（つくり話・絵は生成AIで作成）
声は AI 音声（ElevenLabs）です。

#ハコブくん #ロボット #4コママンガ

=== 第9話_外装/ショート_タイトルと説明文.txt
■ 公開予約（YouTube Studio で設定）
10/27（火）12:30

■ タイトル
外装を外したら、中から光る目がギョロッ。かわいいロボットの中身 #フィジカルai #ロボット

■ 説明文
看護師の高杉さんが、機材室で休んでいるハコブくんに「お疲れ様〜」と声をかけに行くと、
外装を外されたハコブくんの中から、光るセンサーがギョロッ。エンジニアの槙原さんは「外装外してるだけですよ」。

かわいさは、外装がつくっていた。見た目が信頼を守っている、という話の4コママンガ『ハコブくん』第9話です。

マンガの全話: https://pixelstomotors.com/manga/ep09
ほかの動画: https://www.youtube.com/@PixelstoMotors

4コママンガ『ハコブくん』第9話より（つくり話・絵は生成AIで作成）
声は AI 音声（ElevenLabs）です。

#ハコブくん #ロボット #4コママンガ

=== 第17話_イケボ/ショート_タイトルと説明文.txt
■ 公開予約（YouTube Studio で設定）
10/28（水）12:30

■ タイトル
アップデートで「御意。ただいま参上つかまつった、姫君」と言い出したロボット #フィジカルai #ロボット

■ 説明文
「親しみやすさ」を重視した音声アップデート。
ハコブくんを呼んでみると、返ってきたのは超重低音のイケメンボイスでした。

親しみやすさは、キャラの濃さではない。現場がほしいのは、いつも同じにふるまう道具。4コママンガ『ハコブくん』第17話です。

マンガの全話: https://pixelstomotors.com/manga/ep17
ほかの動画: https://www.youtube.com/@PixelstoMotors

4コママンガ『ハコブくん』第17話より（つくり話・絵は生成AIで作成）
声は AI 音声（ElevenLabs）です。

#ハコブくん #ロボット #4コママンガ

=== 第23話_忍者/ショート_タイトルと説明文.txt
■ 公開予約（YouTube Studio で設定）
10/29（木）12:30

■ タイトル
静音モードにしたら、気配ゼロで背後にいる忍者みたいなロボット #フィジカルai #ロボット

■ 説明文
音を消した静音モードのハコブくん。
廊下の角で、看護師の大城さんの目の前に、音もなく現れました。

静かすぎるロボットは、かえってこわい。気配も仕様のうち、という4コママンガ『ハコブくん』第23話です。

マンガの全話: https://pixelstomotors.com/manga/ep23
ほかの動画: https://www.youtube.com/@PixelstoMotors

4コママンガ『ハコブくん』第23話より（つくり話・絵は生成AIで作成）
声は AI 音声（ElevenLabs）です。

#ハコブくん #ロボット #4コママンガ

=== 第46話_メニュー/ショート_タイトルと説明文.txt
■ 公開予約（YouTube Studio で設定）
10/30（金）12:30

■ タイトル
音量を下げたいだけなのに、設定がどこまでも深いロボット #フィジカルai #ロボット

■ 説明文
看護師の大城さんが、夜勤用にハコブくんの音量を下げようとすると、
設定＞運用＞出力＞音声＞音量＞時間帯別…と、メニューがどこまでも続きます。たどり着く前に、ナースコール。

よく使う設定ほど、手前に置く。急いでいる人の画面設計を描いた、4コママンガ『ハコブくん』第46話です。

マンガの全話: https://pixelstomotors.com/manga/ep46
ほかの動画: https://www.youtube.com/@PixelstoMotors

4コママンガ『ハコブくん』第46話より（つくり話・絵は生成AIで作成）
声は AI 音声（ElevenLabs）です。

#ハコブくん #ロボット #4コママンガ

=== 第47話_8時間/ショート_タイトルと説明文.txt
■ 公開予約（YouTube Studio で設定）
11/2（月）12:30

■ タイトル
8時間ずっと「メンテナンス推奨」と表示していたのに、誰も気づかなかったロボット #フィジカルai #ロボット

■ 説明文
ハコブくんの画面のすみに、小さく「右車輪のメンテナンス推奨」。
看護師の寺島さんは「あ、後でね」。ハコブくんが傾き始めて、大城さんがやっと気づきました。

表示しただけでは、伝わったことにならない。4コママンガ『ハコブくん』第47話です。

マンガの全話: https://pixelstomotors.com/manga/ep47
ほかの動画: https://www.youtube.com/@PixelstoMotors

4コママンガ『ハコブくん』第47話より（つくり話・絵は生成AIで作成）
声は AI 音声（ElevenLabs）です。

#ハコブくん #ロボット #4コママンガ

=== 第51話_自己診断/ショート_タイトルと説明文.txt
■ 公開予約（YouTube Studio で設定）
11/4（水）12:30

■ タイトル
目が悪くなったのに、自己診断は「全機能、正常」と言い張るロボット #フィジカルai #ロボット

■ 説明文
最近、見落としが増えたハコブくん。自己診断の結果は「全機能、正常」。
でも原因は、カメラのレンズについた小さなホコリでした。

センサーの汚れは、そのセンサーでは見つけられない。外から点検する人が要る、という4コママンガ『ハコブくん』第51話です。

マンガの全話: https://pixelstomotors.com/manga/ep51
ほかの動画: https://www.youtube.com/@PixelstoMotors

4コママンガ『ハコブくん』第51話より（つくり話・絵は生成AIで作成）
声は AI 音声（ElevenLabs）です。

#ハコブくん #ロボット #4コママンガ

=== 第52話_夜勤/ショート_タイトルと説明文.txt
■ 公開予約（YouTube Studio で設定）
11/5（木）12:30

■ タイトル
気をきかせて「今月の夜勤、40%多いです」。シフト表を見ていたロボット #フィジカルai #ロボット

■ 説明文
休憩中の看護師の大城さんに、ハコブくんが「今月の夜勤は、過去6カ月平均より40%多いです」。
気づかいのつもりでしたが、その数字はシフト表を勝手に見て出したものでした。

気づかいでも、どこで知ったかが見えないとこわい。4コママンガ『ハコブくん』第52話です。

マンガの全話: https://pixelstomotors.com/manga/ep52
ほかの動画: https://www.youtube.com/@PixelstoMotors

4コママンガ『ハコブくん』第52話より（つくり話・絵は生成AIで作成）
声は AI 音声（ElevenLabs）です。

#ハコブくん #ロボット #4コママンガ

=== 第60話_再現しない/ショート_タイトルと説明文.txt
■ 公開予約（YouTube Studio で設定）
11/6（金）12:30

■ タイトル
エンジニアが見張っている間は、絶対にバグが起きないロボット #フィジカルai #ロボット

■ 説明文
週に2〜3回、急に止まるハコブくん。エンジニアの白石さんが調べに来ても、見ている間はまったく起きません。
帰ったとたん、ピタッ。

見ていない時にだけ起きる不具合は、現場の目で捕まえる。4コママンガ『ハコブくん』第60話です。

マンガの全話: https://pixelstomotors.com/manga/ep60
ほかの動画: https://www.youtube.com/@PixelstoMotors

4コママンガ『ハコブくん』第60話より（つくり話・絵は生成AIで作成）
声は AI 音声（ElevenLabs）です。

#ハコブくん #ロボット #4コママンガ

=== 第62話_確信度/ショート_タイトルと説明文.txt
■ 公開予約（YouTube Studio で設定）
11/9（月）12:30

■ タイトル
「了解しました！」と即答。でも本当は自信62%だったロボット #フィジカルai #ロボット

■ 説明文
早口で頼まれた薬の名前に、ハコブくんは「了解しました」と即答して走り出しました。
でも途中で止まって、引き返してきます。聞き取れた自信は、62%しかありませんでした。

まちがえる前に「自信がない」と言える機械のほうが、任せやすい。4コママンガ『ハコブくん』第62話です。

マンガの全話: https://pixelstomotors.com/manga/ep62
ほかの動画: https://www.youtube.com/@PixelstoMotors

4コママンガ『ハコブくん』第62話より（つくり話・絵は生成AIで作成）
声は AI 音声（ElevenLabs）です。

#ハコブくん #ロボット #4コママンガ

=== 第63話_通行止め/ショート_タイトルと説明文.txt
■ 公開予約（YouTube Studio で設定）
11/10（火）12:30

■ タイトル
昨日からできた壁の前で「経路、再計算」をくり返すロボット #フィジカルai #ロボット

■ 説明文
病棟の通路が、工事のパーテーションでふさがれました。人は貼り紙を見て遠回りしますが、
ハコブくんの地図は昨日のまま。「経路、再計算」が止まりません。

通路を変えたら、ロボットの地図も同じ日に直す。4コママンガ『ハコブくん』第63話です。

マンガの全話: https://pixelstomotors.com/manga/ep63
ほかの動画: https://www.youtube.com/@PixelstoMotors

4コママンガ『ハコブくん』第63話より（つくり話・絵は生成AIで作成）
声は AI 音声（ElevenLabs）です。

#ハコブくん #ロボット #4コママンガ

=== 第65話_道案内/ショート_タイトルと説明文.txt
■ 公開予約（YouTube Studio で設定）
11/11（水）12:30

■ タイトル
道を聞いただけなのに「職員認証が必要です」と答えるロボット #フィジカルai #ロボット

■ 説明文
病室の場所がわからない面会の方が、ハコブくんに道をたずねると、
返ってきたのは「職員認証が、必要です」。

守ることと、閉じることはちがう。認証を機能ごとに分ける話の4コママンガ『ハコブくん』第65話です。

マンガの全話: https://pixelstomotors.com/manga/ep65
ほかの動画: https://www.youtube.com/@PixelstoMotors

4コママンガ『ハコブくん』第65話より（つくり話・絵は生成AIで作成）
声は AI 音声（ElevenLabs）です。

#ハコブくん #ロボット #4コママンガ

=== 第71話_置いてけぼり/ショート_タイトルと説明文.txt
■ 公開予約（YouTube Studio で設定）
11/12（木）12:30

■ タイトル
「こちらです」と案内したら、振り返ると誰もついてきていないロボット #フィジカルai #ロボット

■ 説明文
面会に来た森田久美子さんを、ハコブくんが病棟まで案内します。
でも速さは、薬を運ぶときのまま。角を曲がると、久美子さんの姿はありませんでした。

案内の速さは、案内される人が決める。4コママンガ『ハコブくん』第71話です。

マンガの全話: https://pixelstomotors.com/manga/ep71
ほかの動画: https://www.youtube.com/@PixelstoMotors

4コママンガ『ハコブくん』第71話より（つくり話・絵は生成AIで作成）
声は AI 音声（ElevenLabs）です。

#ハコブくん #ロボット #4コママンガ

=== 第72話_トイレ/ショート_タイトルと説明文.txt
■ 公開予約（YouTube Studio で設定）
11/13（金）12:30

■ タイトル
「トイレどこ？」と聞いても「経路を継続します」しか言わないロボット #フィジカルai #ロボット

■ 説明文
入院する中村拓也さんを、ハコブくんが病室まで案内中。
途中で「ちょっと、トイレどこ？」と聞かれても、返事は「経路を、継続します」だけでした。

人はまっすぐには歩かない。寄り道と再開を設計する話の4コママンガ『ハコブくん』第72話です。

マンガの全話: https://pixelstomotors.com/manga/ep72
ほかの動画: https://www.youtube.com/@PixelstoMotors

4コママンガ『ハコブくん』第72話より（つくり話・絵は生成AIで作成）
声は AI 音声（ElevenLabs）です。

#ハコブくん #ロボット #4コママンガ

=== 第84話_プリン/ショート_タイトルと説明文.txt
■ 公開予約（YouTube Studio で設定）
11/16（月）12:30

■ タイトル
先生の声まねで「プリン持ってきて」。本物の先生に届けてしまったロボット #フィジカルai #ロボット

■ 説明文
入院中の中村さんが、田中先生の声をまねて「プリンを持ってきてくれ。至急だ」。
ハコブくんは声が一致したので受け付けて、回診に来た本物の田中先生にプリンを届けてしまいました。

声が似ていることは、本人の証明にならない。4コママンガ『ハコブくん』第84話です。

マンガの全話: https://pixelstomotors.com/manga/ep84
ほかの動画: https://www.youtube.com/@PixelstoMotors

4コママンガ『ハコブくん』第84話より（つくり話・絵は生成AIで作成）
声は AI 音声（ElevenLabs）です。

#ハコブくん #ロボット #4コママンガ

=== 第89話_お手すき/ショート_タイトルと説明文.txt
■ 公開予約（YouTube Studio で設定）
11/17（火）12:30

■ タイトル
「お手すきの時で…」と頼まれて、本当に後回しにしたロボット #フィジカルai #ロボット

■ 説明文
急変の対応中、研修医の小芝先生は、遠慮して「お手すきの時で…急がなくて大丈夫なんですけど」と頼んでしまいました。
ハコブくんは言葉どおり、優先度「低」で登録します。

急ぎの用ほど、人は遠慮してやんわり言う。だから機械の側から「急ぎですか」と聞く。4コママンガ『ハコブくん』第89話です。

マンガの全話: https://pixelstomotors.com/manga/ep89
ほかの動画: https://www.youtube.com/@PixelstoMotors

4コママンガ『ハコブくん』第89話より（つくり話・絵は生成AIで作成）
声は AI 音声（ElevenLabs）です。

#ハコブくん #ロボット #4コママンガ

=== 第90話_伝言/ショート_タイトルと説明文.txt
■ 公開予約（YouTube Studio で設定）
11/18（水）12:30

■ タイトル
遠慮がちな伝言を要約したら「指示に誤りがあります」と断定してしまったロボット #フィジカルai #ロボット

■ 説明文
研修医の小芝先生が、ためらいながら「見間違いかもしれないんですけど…」と頼んだ伝言。
ハコブくんは要約して、田中先生に「指示に、誤りがあります」と届けました。

伝言から、ためらいが消えると意味が変わる。4コママンガ『ハコブくん』第90話です。

マンガの全話: https://pixelstomotors.com/manga/ep90
ほかの動画: https://www.youtube.com/@PixelstoMotors

4コママンガ『ハコブくん』第90話より（つくり話・絵は生成AIで作成）
声は AI 音声（ElevenLabs）です。

#ハコブくん #ロボット #4コママンガ

=== 第91話_心拍数/ショート_タイトルと説明文.txt
■ 公開予約（YouTube Studio で設定）
11/19（木）12:30

■ タイトル
笑顔で隠した緊張を「心拍数、平常時の1.8倍です」と発表してしまうロボット #フィジカルai #ロボット

■ 説明文
はじめて一人で採血をする研修医の小芝先生。笑顔で緊張を隠していたのに、
ハコブくんが患者さんの前で「心拍数、平常時の1.8倍です」と読み上げてしまいました。

正しい情報でも、届け先をまちがえると暴露になる。4コママンガ『ハコブくん』第91話です。

マンガの全話: https://pixelstomotors.com/manga/ep91
ほかの動画: https://www.youtube.com/@PixelstoMotors

4コママンガ『ハコブくん』第91話より（つくり話・絵は生成AIで作成）
声は AI 音声（ElevenLabs）です。

#ハコブくん #ロボット #4コママンガ

=== 第92話_なかったことに/ショート_タイトルと説明文.txt
■ 公開予約（YouTube Studio で設定）
11/20（金）12:30

■ タイトル
「さっきの、なかったことにできない？」と頼まれて、きっぱり断るロボット #フィジカルai #ロボット

■ 説明文
病室番号を言いまちがえた研修医の小芝先生が、ハコブくんにこっそり「なかったことに、できない？」。
答えは「記録は、消せません。ですが、報告は一緒にできます」。

消せない記録は、責める材料ではなく報告の足がかり。4コママンガ『ハコブくん』第92話です。

マンガの全話: https://pixelstomotors.com/manga/ep92
ほかの動画: https://www.youtube.com/@PixelstoMotors

4コママンガ『ハコブくん』第92話より（つくり話・絵は生成AIで作成）
声は AI 音声（ElevenLabs）です。

#ハコブくん #ロボット #4コママンガ

=== 第98話_起こさない/ショート_タイトルと説明文.txt
■ 公開予約（YouTube Studio で設定）
11/24（火）12:30

■ タイトル
「朝まで絶対に起こさないで」を、本当に一晩守りきったロボット #フィジカルai #ロボット

■ 説明文
仮眠に入る研修医の小芝先生に「朝まで、絶対に起こさないでね」と言われたハコブくん。
承認待ちが3件たまっても、朝まで一度も起こしませんでした。

「絶対に」にも、破っていい例外がある。例外の条件を先に決めておく話の4コママンガ『ハコブくん』第98話です。

マンガの全話: https://pixelstomotors.com/manga/ep98
ほかの動画: https://www.youtube.com/@PixelstoMotors

4コママンガ『ハコブくん』第98話より（つくり話・絵は生成AIで作成）
声は AI 音声（ElevenLabs）です。

#ハコブくん #ロボット #4コママンガ
```

### r2r2r-content tools/sync_youtube_shorts.py

YouTube の公開予約から X の予定表に入れる（新しく作った）

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

元になるもの:
  sns/SHORT_ep{N}_sns_posts.json   ショートの X の文（posts.x_template。{url} がショートの URL に替わる）
  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」）
  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()
    vids = [v for v in load(a.videos, {}).get("動画", []) if 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"))):
        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"))]
        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']}"
        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())
```

### texts.py（ショートの X の文。sns/SHORT_ep{N}_sns_posts.json を作ったクラウドの作業環境のスクリプト）

```python
# ショート31本の X の文（{url} はショートの URL に置き換わる）。2026-10-24
T = {
 # --- YouTube で予約済みの11本 ---
 41: ("同時に呼ばれた", "「こっち！」「こっちが先！」\nほぼ同時に呼ばれたロボット、公平に決めようとして、二人のちょうど真ん中で待ち始めた。\n\n完ぺきな公平は、全員を待たせるんだよね。4コマ『ハコブくん』のショートだよ", "#ロボット #病院"),
 22: ("消毒", "「感染対策、強化月間です」\nロボットが、看護師さんにまで消毒を吹きかけ続ける。\n\n目的が正しいほど、機械には止まる理由がないんだよね。どこまでで十分か、先に決めておきたい。4コマ『ハコブくん』のショート", "#ロボット #感染対策"),
 24: ("シュレッダー", "机の上の紙を「ゴミ」と判断して、シフト表を粉々にしたロボット。\n\nゴミか大事な物かは、紙の見た目には書いてない。だから、迷ったら触らない。消したら戻せない動きほど慎重に、だね。4コマ『ハコブくん』のショート", "#ロボット #病院"),
 29: ("ハワイ", "「私は夜勤か…」とぼやいたら、ロボットの画面がクルクル回って「ハワイ旅行」。\n\n役に立つ情報はゼロ。でも夜勤が少し軽くなる。効率の外にも、ロボットの仕事はあるかも。4コマ『ハコブくん』のショート", "#ロボット #夜勤"),
 43: ("謝罪", "誰も気づかない小さなミスのあと、ずっと「申し訳ありませんでした」をくり返すロボット。仕事が進まない…\n\n謝るのは大事。でも引き際も大事だね。4コマ『ハコブくん』のショート", "#ロボット #病院"),
 81: ("かぼちゃ", "「前方が、全面オレンジ色です」\nハロウィンのかぼちゃ帽子で、前が見えなくなったロボット。\n\n飾りは大歓迎。ふさいじゃいけない所だけ、先に教えておきたいね。4コマ『ハコブくん』のショート", "#ロボット #ハロウィン"),
 82: ("ため息", "看護師さんの「はぁ…」をまねして、どんどん深いため息になっていくロボット。\n\nまねは寄り添う近道。でも疲れまで映し返すと、場が沈むんだよね。4コマ『ハコブくん』のショート", "#ロボット #看護師"),
 83: ("確認", "確認しすぎたロボット。返事がだんだん軽くなって、「初期化しますか？」にも「わかった！」。\n\n確認が多すぎると、いちばん大事な確認が埋もれる。重さの伝え方が大事かも。4コマ『ハコブくん』のショート", "#ロボット #UX"),
 85: ("取り消し", "「今の取り消し！」と言われて、ピーピー鳴らしながら来た道をバックで戻るロボット。\n\n頼んだことを消すのと、動いた分を戻すのは別の話なんだよね。4コマ『ハコブくん』のショート", "#ロボット #病院"),
 87: ("小声", "「声は小さくね」と頼んだら、薬の取り違えまでひそひそ声で伝えたロボット。\n\n小声にしたのは正しい。でも大事なことまで小さくすると届かない…4コマ『ハコブくん』のショート", "#ロボット #病院"),
 88: ("ついてきて", "「ついてきて」と言われて、帰りのバス停までついてきたロボット。\n\n始める合図はあっても、終わりの合図がなかったんだよね。どこで終わるかも一緒に決めたい。4コマ『ハコブくん』のショート", "#ロボット #病院"),
 # --- 新しい20本 ---
 7: ("ピンポン", "深刻な告知の真っ最中に「ピンポンパンポーン！検便容器を回収してまいりました！」\n\n言ってることは正しい。でも場の空気が読めないと最悪の一言になる…4コマ『ハコブくん』のショート", "#ロボット #病院"),
 9: ("外装", "外装を外したロボットの中から、光る目がギョロッ。\n\nかわいさって、外装が作ってたんだね。見た目は飾りじゃなくて、信頼を守る器かも。4コマ『ハコブくん』のショート", "#ロボット #デザイン"),
 17: ("イケボ", "親しみやすさ重視のアップデートで、ロボットが「御意。ただいま参上つかまつった、姫君」\n\nキャラが濃すぎて仕事にならない（笑）親しみやすさって、キャラの濃さじゃないんだよね。4コマ『ハコブくん』のショート", "#ロボット #UX"),
 23: ("忍者", "静音モードにしたら、音もなく背後に現れるロボットに。看護師さん「ひっ！」\n\n静かすぎるのも、こわいんだよね。気配も仕様のうちかも。4コマ『ハコブくん』のショート", "#ロボット #病院"),
 46: ("メニュー", "音量を下げたいだけなのに、設定＞運用＞出力＞音声＞音量＞時間帯別…\nたどり着く前にナースコール。\n\nよく使う設定ほど手前に置きたいね。4コマ『ハコブくん』のショート", "#UIデザイン #ロボット"),
 47: ("8時間", "ロボットは8時間ずっと「右車輪のメンテナンス推奨」と表示してた。誰も気づかなかった…\n\n表示しただけじゃ、伝わったことにならないんだよね。4コマ『ハコブくん』のショート", "#ロボット #UX"),
 51: ("自己診断", "見落としが増えたロボット。自己診断は「全機能、正常」。原因はレンズの小さなホコリ。\n\n自分の目は、自分で見えない。外から点検する人がいるんだよね。4コマ『ハコブくん』のショート", "#ロボット #保守"),
 52: ("夜勤", "「今月の夜勤は、平均より40%多いです」\n気づかいのつもりのロボット。でも、その数字はシフト表を勝手に見て出したもの。\n\n見る前にひとこと、がほしいね。4コマ『ハコブくん』のショート", "#ロボット #プライバシー"),
 60: ("再現しない", "エンジニアが見張ってる間は、絶対に急停止しないロボット。帰ったとたん、ピタッ。\n\n見てない時だけ起きる不具合は、現場の目で捕まえるしかないかも。4コマ『ハコブくん』のショート", "#ロボット #バグ"),
 62: ("確信度", "「了解しました！」と即答して走り出したロボット。でも聞き取れた自信は62%だった…\n\n迷ってるなら、迷ってる顔をしてくれた方が助かるんだよね。4コマ『ハコブくん』のショート", "#ロボット #AI"),
 63: ("通行止め", "昨日からできた壁の前で「経路、再計算」をくり返すロボット。\n\n貼り紙は読めても、地図は書き換わってない。通路を変えたら地図も同じ日に直したいね。4コマ『ハコブくん』のショート", "#ロボット #病院"),
 65: ("道案内", "面会の人に「304号室はどっち？」と聞かれて、ロボットの答えは「職員認証が必要です」。\n\n守ることと閉じることは違うんだよね。4コマ『ハコブくん』のショート", "#ロボット #セキュリティ"),
 71: ("置いてけぼり", "「こちらです」と案内したロボット。振り返ったら、誰もついてきてなかった。\n\n案内の速さは、案内される人が決めるんだよね。4コマ『ハコブくん』のショート", "#ロボット #病院"),
 72: ("トイレ", "「ちょっと、トイレどこ？」\n案内中のロボットの答えは「経路を、継続します」…\n\n人はまっすぐには歩かない。寄り道も最初から設計したいね。4コマ『ハコブくん』のショート", "#ロボット #UX"),
 84: ("プリン", "患者さんが先生の声まねで「プリン持ってきて」。ロボットは本物の先生にプリンを届けた（笑）\n\n声が似てても、本人の証明にはならないんだよね。4コマ『ハコブくん』のショート", "#ロボット #音声認識"),
 89: ("お手すき", "急ぎなのに「お手すきの時で…」と頼んだら、ロボットは本当に後回しにした。\n\n急ぎの用ほど人はやんわり言う。だから機械の側から「急ぎですか」と聞きたいね。4コマ『ハコブくん』のショート", "#ロボット #病院"),
 90: ("伝言", "「見間違いかもしれないんですけど…」の伝言を要約したら、ロボットは「指示に誤りがあります」と断定してしまった。\n\nためらいも、伝言の中身なんだよね。4コマ『ハコブくん』のショート", "#ロボット #AI"),
 91: ("心拍数", "笑顔で緊張を隠してた先生。ロボットが患者さんの前で「心拍数、平常時の1.8倍です」…\n\n正しい情報でも、届け先をまちがえると暴露になるね。4コマ『ハコブくん』のショート", "#ロボット #病院"),
 92: ("なかったことに", "「さっきのミス、なかったことにできない？」\nロボットの答えは「記録は消せません。ですが、報告は一緒にできます」\n\nこれは頼もしいかも。4コマ『ハコブくん』のショート", "#ロボット #医療安全"),
 98: ("起こさない", "「朝まで絶対に起こさないで」を、一晩守りきったロボット。朝、承認待ちが3件…\n\n「絶対に」にも、破っていい例外がある。先に決めておきたいね。4コマ『ハコブくん』のショート", "#ロボット #病院"),
}
```

### クラウドの作業環境 runall.sh

2本ずつ並べて書き出す

```bash
#!/bin/sh
# 使い方: runall.sh モード 番号…（2本ずつ並べて動かす）
cd /home/claude/w
M=$1; shift
rm -f done_$M.txt log_${M}_a.txt log_${M}_b.txt
i=0; A=""; B=""
for n in "$@"; do if [ $((i % 2)) -eq 0 ]; then A="$A $n"; else B="$B $n"; fi; i=$((i+1)); done
[ -n "$A" ] && python3 build.py $M $A > log_${M}_a.txt 2>&1 &
[ -n "$B" ] && python3 build.py $M $B > log_${M}_b.txt 2>&1 &
wait
echo done > done_$M.txt
```

### クラウドの作業環境 sheets.py

各場面の静止画を並べて1枚にする

```python
import glob, os
from PIL import Image
S = "/tmp/claude-0/-home-claude/46acca64-48dd-5d9f-b89b-15a7aa0615ef/scratchpad/"
eps = sorted(glob.glob("ep/*/"), key=lambda d: int(d.split("第")[1].split("話")[0]))
rows = []
for d in eps:
    fs = sorted(glob.glob(d + "still_*.png"), key=lambda f: float(f.rsplit("_", 1)[1][:-4]))[:8]
    rows.append([Image.open(f).convert("RGB").resize((240, 427)) for f in fs])
for g in range(0, len(rows), 4):
    part = rows[g:g + 4]
    s = Image.new("RGB", (240 * 8, 427 * len(part)), "white")
    for i, r in enumerate(part):
        for j, im in enumerate(r): s.paste(im, (240 * j, 427 * i))
    s.save(S + f"ov{g // 4}.jpg", quality=78)
print("ok")
```

### クラウドの作業環境 outsheet.py

書き出した動画から9コマを取り出して並べる

```python
import glob, subprocess, io, sys
from PIL import Image
S = "/tmp/claude-0/-home-claude/46acca64-48dd-5d9f-b89b-15a7aa0615ef/scratchpad/"
eps = [int(x) for x in sys.argv[1:]]
rows = []
for n in eps:
    f = glob.glob(f"ep/第{n}話_*/out_*.mp4")[0]
    dur = float(subprocess.run(["ffprobe","-v","error","-show_entries","format=duration","-of","csv=p=0",f],capture_output=True,text=True).stdout)
    ims = []
    for k in range(9):
        t = 0.4 + (dur - 0.8) * k / 8
        r = subprocess.run(["ffmpeg","-v","error","-ss",f"{t:.2f}","-i",f,"-frames:v","1","-f","image2pipe","-vcodec","png","-"],capture_output=True)
        ims.append(Image.open(io.BytesIO(r.stdout)).convert("RGB").resize((200, 356)))
    rows.append(ims)
s = Image.new("RGB", (200 * 9, 356 * len(rows)), "white")
for i, r in enumerate(rows):
    for j, im in enumerate(r): s.paste(im, (200 * j, 356 * i))
s.save(S + "out_" + "_".join(map(str, eps)) + ".jpg", quality=78)
```

### r2r2r-content docs/sns_autopost.md に足した節

````markdown
### YouTube のショート（sns/SHORT_ep{N}_sns_posts.json）

ショート専用のマンガのショートは、YouTube の公開予約に合わせて X に出す（2026-10-24 エイイチの指示「ショートもXに出す」「19:30が重なったら動画は20:00」）。

```json
{"meta": {"kind": "youtube_short", "episode": 7, "title": "ショート 第7話 ピンポン",
          "youtube_title": "YouTube のタイトル", "manga_url": "https://pixelstomotors.com/manga/ep07",
          "url": "（自動）", "youtube_id": "（自動）"},
 "posts": {"x_template": "本文…\n\n{url}\n#ロボット #病院", "x": "（自動）"}}
```

- 文は動画を作った会話で作って置く（`x_template`。`{url}` がショートの URL に替わる）。タグは話題のタグ2つ、エイイチの話し方
- `tools/sync_youtube_shorts.py --videos youtube_videos.json` が、エイイチの Mac が1時間ごとに書き出す YouTube の一覧（`_制作ベース/youtube_videos.json`）を読み、説明文の `manga/ep{N}`（`manga_ep`）で json と動画を結びつけて、公開の時刻で予定表に入れる。`url` と `posts.x` もそのときに入れる
- 同じ時刻に X の予約があれば、19:30 なら 20:00、それ以外は30分ずつ後ろへずらす
- YouTube で公開の時刻を変えたら、まだ投稿していない予約の時刻も次の同期で直る。公開の時刻を過ぎてから見つけたものは入れない
- LinkedIn には入れない（動画は LinkedIn に直接アップロードする決まりのため、手で出す）
- 同期は予定のタスク「ショートのX予約をYouTubeに合わせる」が1日2回（7:47・17:47）動かす。Mac が眠っていると、その回は何もしない
- YouTube へのアップロードと公開予約は、まだ手で行う（API で上げるには Google の審査が要る。審査前に API で上げた動画は非公開から変えられない）
````

### 予定のタスク「ショートのX予約をYouTubeに合わせる」の指示文

毎日 7:47・17:47（日本時間）に動く。Mac のフォルダ（`PixelstoMotors`）を使い、承認なしで動く設定。

```text
あなたは、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 だけを 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 のショート」の節が正。LinkedIn の予約は入れない。予定表のほかの行（記事・マンガ）は触らない。
```
