YouTubeショートを作り直す ― 81%がスワイプされていた

YouTubeショートを作り直す ― 81%がスワイプされていた

YouTube のショート動画(縦長で60秒までの短い動画。スマホでは上へスワイプすると次の動画に切り替わる)が、28日間で2.3万回見られていました。数字だけなら悪くありません。ところが同じ分析画面に、もう1つ数字がありました。ショートを見た人の81.0%は、スワイプして次の動画へ流していました。 見続けたのは19.0%です。チャンネル登録は28日間で7人増えただけでした。

この時点のショートは、ロボットの解説動画(本編・10分前後)の一部を縦長に作り直したものでした。本編を短くまとめた「予告編」のような作りです。ショートの見られ方を見ると、96.1%はショートの流れ(ショートフィード)から来ていました。見ている人は、このチャンネルを探して来たのではありません。次々に流れてくる動画の1本として、たまたま目にした人です。その人にとっては、本編の一部では止まる理由になりません。

そこで、ショートを作り直しました。本編を縮めるのはやめ、ショートだけの中身にします。中身にしたのは、このサイトで連載している4コママンガ『ハコブくん』です。病院で働く配膳ロボットのハコブくんが、人の言葉をまっすぐ受け取りすぎて失敗するつくり話です。マンガのコマをそのまま並べるのではなく、最初の数秒でハコブくんが動きます。暴走して壁にぶつかる、跳ねて喜ぶ、何度もお辞儀する。看護師さんたちにも声を付けました。

やったことなぜ結果
YouTube の分析画面を読んだショートの何が足りないかを知るため81.0% がスワイプで流され、どのショートも約1,100〜1,400回で止まっていた
ショートの中身を本編の縮小版からマンガに変えた流れてきた1〜2秒で止まってもらうため4コママンガ『ハコブくん』の10話を約20秒ずつの動画にした
ハコブくんをコードで動かした動画を作る AI は有料か、作れなかった暴走・大喜び・落ちこむなど、ハコブくんの動きを22種類(動きをつないだもの4つを含む)作った
看護師さんたちに声を付けたセリフを字幕だけで読ませるより伝わるためElevenLabs(声を作る AI)で看護師・エンジニア・医師の6人の声を作り、全員のセリフを声にした
8本を「台本ファイル」から組む仕組みにした1本ずつ手で作ると直すのが遅いため台本を直して書き出し直すだけで、映像と音が1本にそろう
見せてもらって直した背景がいつも同じ・頭が切れる・動きが似ている、などの指摘9回の指摘で、背景・体・字幕・動きを直した

作り直したショートは、この記事の時点ではまだ公開していません。効果が出たかどうかは、公開後に同じ分析画面の「視聴を継続」の割合(いまは19.0%)が上がるかで確かめます。

ここから下は、作り直すまでの作業の記録です。指摘を受けて直した所と、そのたびに変えた作り方の決まり(video_rules.md)、使ったコードの全文を残します。

作業ログ ― YouTube の分析画面で、ショートの81%がスワイプされていた

YouTube Studio の「アナリティクス」を4画面(概要・コンテンツ・視聴者・トレンド)書き出して読みました。期間は 2026/09/20〜2026/10/17 の28日間です。

YouTube Studio のコンテンツ画面でショートだけを選んだところ。視聴回数2.3万、エンゲージビュー4901、高評価47、登録者+7。見つけた方法はショートフィードが96.1%。視聴を継続19.0%、スワイプして消去81.0%

画面から読み取った数字はこれで全部です。

画面項目値
概要チャンネルの視聴回数(28日間)23,787回
概要総再生時間48.3時間
概要チャンネル登録者+9
概要視聴回数・過去48時間10,372回
コンテンツ(ショート)視聴回数2.3万回
コンテンツ(ショート)エンゲージビュー4,901
コンテンツ(ショート)高評価数47
コンテンツ(ショート)チャンネル登録者+7
コンテンツ(ショート)見つけた方法:ショートフィード96.1%
コンテンツ(ショート)見つけた方法:YouTube 検索2.1%
コンテンツ(ショート)見つけた方法:その他の YouTube 機能1.6%
コンテンツ(ショート)見つけた方法:チャンネルページ0.2%
コンテンツ(ショート)見つけた方法:ブラウジング機能0.1%
コンテンツ(ショート)視聴を継続19.0%
コンテンツ(ショート)スワイプして消去81.0%
視聴者月間視聴者数4,784
視聴者新しい視聴者99.9%
視聴者チャンネル登録者の総再生時間未登録 99.7%・登録済み 0.3%
概要画面の「この期間の上位コンテンツ」。上位10本はすべてショートで、視聴回数は1,121〜1,395回、平均視聴時間は13秒〜41秒

上位10本はすべてショートでした。視聴回数は1,121回から1,395回の間に並んでいます。どれか1本が伸びて残りが伸びない、という形ではありません。どれも同じくらい流され、同じくらいで止まっています。

順位ショート公開日平均視聴時間視聴回数
1中国は1日120億円、日本は?比べる前に確かめた…2026/10/150:26(97.1%)1,395
2スーパーの冷凍庫にサイバー攻撃、温度が変わった…2026/10/160:15(57.0%)1,290
3赤字89億円、全員ロボットにしたら埋まる?ジャ…2026/10/140:13(57.0%)1,244
4AIエージェントは、ロボットの不具合の原因を7回…2026/10/150:35(128.1%)1,241
5セキュリティ人材が足りないのに、報告が多すぎ…2026/10/160:17(69.1%)1,196
6ロボットを入れる前に、何を測っておく?2026/10/160:29(97.7%)1,176
7リコールで車は工場に回収された?2026/10/140:33(71.8%)1,173
8横浜のガンダム、歩けないの? #Shorts2026/10/170:16(58.7%)1,136
9人を18mにしたら、何トン? #Shorts2026/10/170:22(77.7%)1,127
10得点だけを上げるよう学んだAIは、何をした?2026/10/180:41(147.2%)1,121

数字を見て、方針はこう決まりました。

ショートはもっと違う戦略で作った方がいいかもね…ショートは単純に面白い、興味が湧くなどショート特有の内容に変えていく必要があるかもですね

そういったネタを色々試して、よかったものを量産する感じがいいだろうね

最初に試すネタも決まりました。

あと、ハコブくんが暴れていたり…マンガのネタをベースに考えたものを最初に作っていいかもです。

いちばん感情が強いコマ…これは動画でつくちゃうといいかもね。

動画を作る AI は使えず、ハコブくんをコードで動かした

ハコブくんが暴れる3〜5秒の映像を作るのに、最初は動画を作る AI を探しました。サイトのトップにある、病室のハコブくんの映像(FV)が元の絵です。

試したもの結果
ChatGPT動画は作れないという返事
Grok Imagine動画は有料のプラン(SuperGrok)から
Claude動画を作る AI ではないが、コードで絵を動かして動画に書き出せる

claudeが動画は作れないの?

AI に1本ずつ生成させるのではなく、ハコブくんの体の画像を、コード(Python と OpenCV)で揺らす・跳ねさせる・傾ける・倒す、という形にしました。顔(画面の目と口)とリングの光も、コードで描き替えます。形が崩れず、何本でも同じ見た目で作れます。この仕組みを hakobu_motion.py にまとめました。

最初に作った動きの一覧。上から、落ちこむ(涙がひとつぶ)、固まる(ぶつかって×の目)、大喜び(ぴょんぴょん跳ねる)、なぐさめる(うなずいて目がハート)

最初に作った動きは7種類です。大あわて、落ちこむ、固まる、大喜び、なぐさめる、暴走してこける、大あわて(大げさ版)。

いいです! もっと大袈裟に動くバージョンで、画面を左右に動き回って端っこにぶつかってこけるとかも追加!

画面を動き回る動きでは、背景の病室からロボットを消しておく必要があります。元の絵ではロボットのいた所が空白になるので、そこを画像を埋める AI のモデル(LaMa)で埋めました。それが bg_empty.png です。

第79話で型を決めた:冒頭の動き・コマ・声・まとめ

セリフをどう見せるかは、マンガの吹き出しと字幕で見せる形にしました。題材はサイトのマンガから第79話「呼び名は、よく聞こえすぎる」と第80話「数字は目的を知らない」を選びました。

看護師の声作る?作った方が動画としてはいいかもね

看護師さんの声は ElevenLabs の「声をデザインする」機能で作りました。最初の候補は「声が変」と言われました。声を作るモデルを eleven_ttv_v3 にし、説明に「日本語が母語・標準語の抑揚」を入れて作り直すと、通りました。ハコブくんの「オ呼ビデスカ?」は、ひらがなで読ませると抑揚が変になり、漢字まじりの「お呼びですか?」で読ませると自然でした。

止まる音(キキーッ)は何度も作り直しました。ElevenLabs の効果音も、コードで合成した音も「高すぎる」「不快」で、最後は効果音ラボの「靴でブレーキキキキーッ」を0.8秒で切って使いました。

動画の最初に寺島に呼ばれた後に「はい!」をいうまでが早すぎる

上部のテキストが画像(動画)で切れてしまっている。

ハコブくんが返事をするのは、相手が言い終わってから。見出しの2行目は、映像の上端より上に置く。この2つを直して、第79話を型にしました。

いいです!OK。これをベースにしよう!

第79話のショートの6コマ。冒頭は動くハコブくんと吹き出し、キキーッで止まり、マンガのコマを1枚ずつ見せ、最後に何の話かをまとめる
置き場所中身
上(y175〜)ロゴと見出し2行(1行10字前後・86px)
中(y466〜1440)冒頭3〜6秒は動くハコブくん、そのあとマンガのコマを1枚ずつ
中の下「4コママンガ『ハコブくん』第N話より(つくり話・絵は生成AIで作成)」
下(y1488〜)字幕。ハコブくんは水色、人は白、強める語はオレンジ
最後の4.4秒何の話かを2〜4行でまとめる画面

第80話は同じ型で作り、22.8秒になりました。

8本は台本ファイルから組む(マンガショート_mkshort.py)

他の話も作っていきましょう!

10話くらい欲しい(あと)

第79話の組み立てのコードは、第79話の時刻を直接書き込んでいました。8本をそのやり方で作ると、直すたびに8か所を手で合わせることになります。そこで、話ごとの台本ファイル(spec)を読んで1本に組む マンガショート_mkshort.py を作りました。

台本に書くのは、見出し・冒頭の映像と声・コマの切り抜き位置と声・効果音・まとめの文です。コマの場面の長さは、声の長さから自動で決まります。声のファイルは前後の無音を切ってから並べます。

選んだ8話はこれです。

話内容声の人
第81話 かぼちゃかぼちゃをかぶせたら、前が全面オレンジハコブ・高杉・槙原
第85話 取り消し取り消しと言ったら、来た道をバックしたハコブ・高杉・大城
第82話 ため息ナースのため息をまねして覚えたハコブ・大城・寺島
第20話 撫でて毎日なでていたら「なでて」と言い出したハコブ・大城・高杉
第88話 ついてきてついてきてと言ったら、どこまでも来たハコブ・大城・白石
第83話 確認確認しすぎたら、最後は初期化ハコブ・田中
第87話 小声小声でと頼んだら、大事なことまで小声ハコブ・大城・寺島
第43話 謝罪ミスしたあと、謝りつづけるハコブ・大城

槙原さん(エンジニア)と田中先生(医師)は新しく声を作り、全8話ぶんのセリフ50本を 8話の声.sh でまとめて作りました。前後の無音を切った長さを測った結果です。

話人番号声の長さ(秒)始まりまでの無音(秒)
第20話ハコブ011.890.02
第20話大城012.210.04
第20話大城024.760.03
第20話高杉013.060.05
第20話高杉021.220.09
第43話ハコブ010.830.03
第43話ハコブ023.480.02
第43話ハコブ030.920.02
第43話ハコブ042.460.03
第43話大城014.00.03
第81話ハコブ012.430.02
第81話ハコブ021.460.03
第81話槙原012.510.05
第81話高杉013.020.03
第81話高杉021.990.06
第82話ハコブ010.730.07
第82話ハコブ021.810.03
第82話大城010.890.03
第82話大城023.020.05
第82話寺島012.520.02
第82話寺島022.250.04
第83話ハコブ012.370.04
第83話ハコブ021.570.1
第83話ハコブ031.110.05
第83話ハコブ040.910.03
第83話ハコブ052.660.07
第83話田中012.090.05
第83話田中020.190.09
第83話田中031.710.05
第83話田中040.480.05
第83話田中053.060.1
第85話ハコブ012.190.03
第85話ハコブ021.760.02
第85話大城012.120.04
第85話高杉011.790.04
第85話高杉022.140.03
第87話ハコブ010.830.05
第87話ハコブ023.140.05
第87話ハコブ032.140.02
第87話大城012.740.03
第87話大城021.240.03
第87話大城031.760.05
第87話大城041.080.07
第87話寺島011.730.05
第88話ハコブ011.870.04
第88話ハコブ021.890.05
第88話大城011.410.05
第88話大城021.850.05
第88話大城032.690.07
第88話白石013.30.01

第83話は確認のやりとりが多く、全部を順に入れると冒頭だけで15秒を超えます。「最終確認です」から初期化までを冒頭にまとめ、最初の303号室のやりとりは入れませんでした。第87話の小声のセリフは、音を22dB下げ、字幕も小さくしています。聞き取りにくいこと自体が話のネタです。

字幕の2行目が切れ、「。」の穴が白く抜けていた

1回目の8本を見てもらって、最初に返ってきたのは字幕の指摘でした。

下のテキストが切れてるのを修正

あと文字の中の白ポチも修正(黒淵だから白いのは黒にしたほうが綺麗な仕上がり)

これは全部の動画に入れるからね。今後もずっとね

字幕の前後。前は2行目の下が切れ、「。」や「ピ」の丸の中が白く抜けている。後は2行目の下まで入り、穴は縁取りと同じ黒

2行目が切れていたのは、2行を重ねる画像の高さが、行どうしを詰めた分(14px)だけ足りなかったためです。白いポチは、「。」や「ピ」の丸のような閉じた穴の中が、縁取りでふさがらずに透けて、後ろの紙の色が見えていたものです。文字を描いたあと、閉じた穴を探して縁取りと同じ黒で塗るようにしました。最初は穴の判定を「不透明度128より上」にしていて、穴のふちの半透明の画素が残り、小さな点が見えたままでした。判定を250より上に変え、穴を2画素広げてから塗ると消えました。

この決まりは本編・ショート・サムネイルの全部に入れるので、video_rules.md の字幕の節(5節)に書きました。本編とサムネイルの字幕は別のコード(SVG で描く)なので、次にそれを使うときに同じ処理を入れます。

背景が毎回同じ病室だった

動画の背景を話によっては別の背景にできない?(毎回同じすぎ)

ハコブくんは同じ立ち位置・同じ床の高さに合成するので、背景はカメラの高さと床の位置が今の病室と同じ絵である必要があります。背景の絵は ChatGPT で作ってもらうことにしました。今の空の病室(bg_empty.png)を添付して構図をそろえ、話ごとの場所の文を足すプロンプトを書きました。

ChatGPT で作った8枚の背景。日の差す廊下、物品の保管室、ハロウィン飾りのプレイルーム、ナースステーション、エレベーターホール、長い廊下、消灯後の病室、夜のバス停
話場所ハコブくんの体の色
第81話 かぼちゃハロウィン飾りの小児プレイルームそのまま
第85話 取り消し病棟の長い廊下そのまま
第82話 ため息夕方のナースステーション少し暖かい色(赤1.0・緑0.95・青0.86倍)
第20話 撫でて日の差す病棟の廊下そのまま
第88話 ついてきて夜のバス停少し暗く(0.62・0.64・0.78倍)
第83話 確認病棟のエレベーターホールそのまま
第87話 小声消灯後の病室青っぽく暗く(0.5・0.55・0.75倍)
第43話 謝罪物品・検体の保管室そのまま

夜の背景に昼の明るさのハコブくんを置くと浮くので、体だけを色の倍率で染めました。顔とリングは光っている部分なので、染めずに明るいまま残しています。

かぼちゃをかぶっていなかった

かぼちゃの帽子かぶってないね。

第81話の冒頭は、画面をオレンジ色に塗っただけでした。マンガでは大きなかぼちゃを頭にかぶっています。かぼちゃをコード(pumpkin.py)で描いて頭にかぶせ、体と一緒に動くようにしました。

かぼちゃはフェルトっぽい素材に変更!

つやのあるかぼちゃを、つやなし・房がふっくら・表面とふちに細かい毛羽・房の境目に縫い目(ステッチ)のあるフェルトに描き直しました。

かぼちゃの前後。左はつやのあるかぼちゃと病室、右はフェルトのかぼちゃとハロウィン飾りのプレイルーム

ピーピーは声ではなく効果音にした

ピーピーピーは効果音の方がいいね。

第85話のハコブくんのセリフ「バックします。ピー、ピー、ピー。」を「バックします。」で切り、ピーは本物のバックの警告音に近い音(1,050Hz の音を0.25秒)をコードで作って鳴らしました。鳴る時刻は、ハコブくんの側面のリングが点く時刻(0.5秒ごと)にそろえています。ハコブくんが言い終わってから下がり始めるように、動きの時刻も声に合わせて直しました。

ピーピーはOK

ハコブくんの頭がまた切れていた

またハコブくんの頭が切れてるよ!やり直し!前にも同じことあったでしょ!

頭の前後。左は古い体で、頭の上が平らに切れている。右は新しい体で、頭の上まで丸く残っている

動きのコードは、サイトのトップの映像のコマから切り抜いた古い体を使っていました。古い体は、元の映像で頭の上が後ろの枕と重なっていたため、頭の上が平らに欠けています。本編の動画では以前この指摘を受けて新しい体(cut_new.png)に替えていましたが、動きのコードは古い体のままでした。同じ失敗を2回したのは、直した決まりが本編のコードにしか入っていなかったためです。

背景を全部差し替えたので、古い体を使う理由(元の病室の絵と同じコマであること)もなくなっていました。新しい体に替え、ディスプレイの幅を古い体と同じ180pxにそろえ、足もとを y=905 に置きました。

新しい体は頭の分だけ背が高いので、手前に近づく動き(第88話)では頭が映像の上から出ます。書き出しのたびに、体が映像の窓(ショートで使う y172〜937)からはみ出したコマを数えて知らせるようにしました。手前に近づく大きさは1.3倍から1.12倍に下げました。

タイヤのまわりの切り抜きが汚かった

ハコブくんのタイヤのあたり切り抜きが汚い!前も同じことしてたよ。 切り抜き綺麗にするのはタイヤのあたりだけね。他は触らないで!

タイヤの前後。前はタイヤと台座のまわりに白いふちが残っている。後は白いふちが消えている

新しい体は白い背景から切り抜いた画像で、タイヤのまわりに白い背景が細く残っていました。体の下13%の高さだけを対象に、黒いタイヤと透明な所の両方に接している、白っぽくて細い画素だけを消しました。最初の版は、タイヤのそばの明るいパネルのふちまで削ってぎざぎざにしたので、「太い白は体」として残す条件(9×9画素の範囲で削っても残る白は消さない)を足しました。

8本とも動きが似ていた

あとみんなほとんど同じ動きだね。いろいろ作ったやつ使ったのはつくらなかったの?

8本それぞれに新しく動きを作ったものの、ため息・うなずく・お辞儀のように、その場で小さく揺れる動きばかりになっていました。最初に作りためた大きな動きは使っていませんでした。動きをつなぐ仕組み(Seq:何秒目からどの動きの何秒目を使うか)を足し、話の動きの前後に大きな動きをつなぎました。

話冒頭の動き(前)冒頭の動き(後)
第81話 かぼちゃ画面がオレンジでフラフラかぼちゃで前が見えず、右の壁にゴン、左の壁にガン、あおむけにドスン
第82話 ため息ため息がだんだん長くなるため息をまねたあと、しょんぼり沈んで涙。部屋も暗くなる
第20話 撫でてにこにこ揺れて、画面に字ぴょんぴょん跳ねて喜んだあと、画面に「撫デテ下サイ(強制)」
第43話 謝罪お辞儀をくり返す「インシデント…」で大あわてで走り回ったあと、お辞儀をくり返す
第87話 小声その場でうなずく近づいてきて2回うなずき、目がハート
第85話 取り消しバックで遠ざかるそのまま
第88話 ついてきてトコトコ手前へそのまま
第83話 確認固まって再起動そのまま
作り直した8本の冒頭。かぼちゃで壁にぶつかる、バックで下がる、ため息で沈む、画面に撫デテ下サイ、夜のバス停、はじめまして、夜の病室で目がハート、大あわて

かぼちゃで暴走する動きは、かぼちゃの分だけ体より横に大きく、倒れたときに長くなります。走る大きさ・壁の手前で止まる幅・倒れながら横へずれる量の組み合わせを8通り試し、映像の窓からはみ出すコマが0になる組み合わせ(大きさ0.8倍・壁の手前90px・倒れながら左へ200px)を選びました。第20話の跳ねる高さは70pxから45pxに、第43話の走り回る幅は165pxから110pxに下げました。

大きさ壁の手前倒れながらずれる量はみ出すコマ(2コマおきに数えた数)
0.890-2000
0.890-2600
0.8130-2000
0.8130-2600
0.7490-2000
0.7490-2600
0.74130-2000
0.74130-2600

書き出しで出た、はみ出しの知らせはこれです(第43話の1回目。走り回る幅を下げる前)。

書き出し: ハコブくん_かぼちゃで暴走.mp4
書き出し: ハコブくん_バック.mp4
書き出し: ハコブくん_ため息で落ちこむ.mp4
書き出し: ハコブくん_喜んで撫でて.mp4
書き出し: ハコブくん_ついてくる.mp4
書き出し: ハコブくん_再起動.mp4
書き出し: ハコブくん_なぐさめる.mp4
書き出し: ハコブくん_あわてて謝る.mp4
  !体が映像の窓からはみ出す(頭・足が切れる): 8コマ 例 [(0.47, 264, 908), (0.5, 243, 910), (0.53, 230, 905)](秒, 上端, 下端)

第20話の跳ねる高さを下げる前にも、同じ知らせが出ていました。

書き出し: ハコブくん_かぼちゃで暴走.mp4
書き出し: ハコブくん_ため息で落ちこむ.mp4
書き出し: ハコブくん_喜んで撫でて.mp4
  !体が映像の窓からはみ出す(頭・足が切れる): 27コマ 例 [(0.4, 171, 850), (0.43, 163, 843), (0.47, 159, 839)](秒, 上端, 下端)
書き出し: ハコブくん_あわてて謝る.mp4
書き出し: ハコブくん_なぐさめる.mp4

1本の中の流れは、図の通りです。8本それぞれの場面の長さを、書き出したときの値で並べました。

第85話の流れを8コマで。リネンを頼まれる、取り消しと言われる、バックしますと言う、下がっていく、人がよけるコマ、元の位置に戻ったコマ、止まるだけでよかったのよのコマ、まとめの画面

書き出した8本の長さと音の大きさです(音の大きさは −16 LUFS に合わせる設定)。

話場面(開始・終了・種類)長さ(秒)音の大きさ
第81話 かぼちゃ(0.0, 6.4, op), (6.4, 9.29, panel), (9.29, 12.7, panel), (12.7, 15.06, panel), (15.06, 19.46, end)19.43-17.4 LUFS
第85話 取り消し(0.0, 8.3, op), (8.3, 10.3, panel), (10.3, 12.96, panel), (12.96, 15.97, panel), (15.97, 20.37, end)20.37-18.1 LUFS
第82話 ため息(0.0, 5.2, op), (5.2, 8.62, panel), (8.62, 12.53, panel), (12.53, 15.68, panel), (15.68, 20.08, end)20.07-16.5 LUFS
第20話 撫でて(0.0, 5.8, op), (5.8, 7.92, panel), (7.92, 11.03, panel), (11.03, 16.69, panel), (16.69, 21.09, end)21.07-17.4 LUFS
第88話 ついてきて(0.0, 5.2, op), (5.2, 7.1, panel), (7.1, 12.12, panel), (12.12, 16.31, panel), (16.31, 22.19, panel), (22.19, 26.59, end)26.57-16.2 LUFS
第83話 確認(0.0, 9.9, op), (9.9, 13.86, panel), (13.86, 18.26, end)18.23-17.3 LUFS
第87話 小声(0.0, 4.3, op), (4.3, 9.91, panel), (9.91, 12.56, panel), (12.56, 15.6, panel), (15.6, 17.58, panel), (17.58, 21.98, end)21.97-16.6 LUFS
第43話 謝罪(0.0, 5.0, op), (5.0, 7.0, panel), (7.0, 11.9, panel), (11.9, 15.26, panel), (15.26, 19.66, end)19.63-16.0 LUFS

KIOXIA に保存したら、冒頭の映像だけ中身が違っていた

8本と、冒頭のハコブくんの映像(素材)を、外付けの SSD(KIOXIA)の videos/_ショート専用/第NN話_名前/ に保存しました。保存したあと、クラウドの作業環境のファイルと中身が同じかを md5(ファイルの中身から計算する短い番号)で比べると、8本は一致し、冒頭の映像の8本は一致しませんでした。送る前に比べたときは一致していたので、送る途中で何が起きたかは分かっていません。以前にも、送るために置いたファイルが別の版に変わっていたことがあり、そのときと同じく、冒頭の映像だけを1つの圧縮ファイルにまとめて送り直し、Mac 側で取り出して置き直しました。

KIOXIA の上では、日本語のファイル名を同じ名前のまま上書きしようとすると「そのファイルはない」と言われました。濁点(「ブ」の「゛」)の持ち方が違う2つの名前が混ざっていたためと見ています。一時的な英数字の名前で書いてから置き換える形にすると通りました。最後の確認の結果です。

True 第81話_かぼちゃ/_素材/冒頭_ハコブくん_かぼちゃで暴走.mp4
True 第85話_取り消し/_素材/冒頭_ハコブくん_バック.mp4
True 第82話_ため息/_素材/冒頭_ハコブくん_ため息で落ちこむ.mp4
True 第20話_撫でて/_素材/冒頭_ハコブくん_喜んで撫でて.mp4
True 第88話_ついてきて/_素材/冒頭_ハコブくん_ついてくる.mp4
True 第83話_確認/_素材/冒頭_ハコブくん_再起動.mp4
True 第87話_小声/_素材/冒頭_ハコブくん_なぐさめる.mp4
True 第43話_謝罪/_素材/冒頭_ハコブくん_あわてて謝る.mp4
213be3a109c34d888696db50ad727ab0  第20話_撫でて/ショート_第20話_撫でて.mp4
a4bb527ddf3107488753ee783428c375  第43話_謝罪/ショート_第43話_謝罪.mp4
c4d2e82c269b4230a6b6f4707f010d70  第79話_呼び名/ショート_第79話_呼び名.mp4
aed39a213a8f95d6f21991366d7b5330  第80話_数字/ショート_第80話_数字.mp4
ae3da69667b358d0495af70844075291  第81話_かぼちゃ/ショート_第81話_かぼちゃ.mp4
38b5956fccb3172db9826bea64385acf  第82話_ため息/ショート_第82話_ため息.mp4
630a55fd5a61ed42f9ac811551f9914c  第83話_確認/ショート_第83話_確認.mp4
fde8861a88f302017e0e48923a7c088c  第85話_取り消し/ショート_第85話_取り消し.mp4
e90c3c0f032573ed409461f958e48836  第87話_小声/ショート_第87話_小声.mp4
95b316a03590387dd7edbbc35f22aa0c  第88話_ついてきて/ショート_第88話_ついてきて.mp4

video_rules.md に足したこと(71〜73回目)

動画の作り方の決まりは video_rules.md にまとめていて、指摘を受けるたびに書き足しています。今回足した行です。

- 縁取りの中の穴(「。」「゜」「ぱ行の丸」など)は、縁取りの色(黒)で埋める。穴が透けて白いポチに見えるため(2026-10-19 エイイチの指示。本編・ショート・サムネイルの全部に、今後ずっと入れる)。PIL で描くときは、描いたあと穴を探して縁取りの色で塗る(マンガショート_mkshort.py の text_img が見本)。SVG で描く字幕(render.py・assemble_short*.py・thumbnail.py)は、次に使うときに同じ処理を入れ、静止画で穴が黒いことを確かめる
- 2行の字幕は、下の行の縁取りまで切れずに入っているかを静止画で確かめる(第85話のショートで下の行が切れていた)
- 第81〜88話ほかの8本で足した動き: かぼちゃ(Blind・画面が全面オレンジでフラフラ)/バック(Reverse・ピーピーと遠ざかる)/ため息(Sigh)/撫でて(Petted・画面に字を出す)/ついてくる(Follow・手前へトコトコ)/再起動(Reboot・固まる→横線→はじめまして)/うなずく(Nod)/お辞儀(Bow)。顔に目の text:行1|行2(画面に字)・line(横線)・orange(画面を塗る)を足した
- 冒頭の動きは、話ごとに違う見え方にする。その場で小さく動くだけの動きを並べると「みんな同じ動き」になる(2026-10-19 エイイチの指摘)。作りためた大きな動き(暴走してこける・大喜び・落ちこむ・大あわて_大げさ・なぐさめる)を、話の動きとつなぐ(Seq:[(始まる秒, パターン, そのパターンの何秒目から)])。例: かぼちゃで暴走(第81話)・ため息で落ちこむ(第82話)・喜んで撫でて(第20話)・あわてて謝る(第43話)
- タイヤのまわりの切り抜きの白いふちは、hakobu_motion.py の _clean_wheels で消す(体の下13%の、タイヤに接する細い白だけ。ほかは触らない。2026-10-19 エイイチの指摘「前も同じことをしていた」)
- 体は頭まで欠けていない新しい体(cut_new.png・BODY="new")を使う。FV のコマから切り抜いた古い体は頭の上が平らに切れて見える(第85話のショートでエイイチに「また頭が切れてる」と指摘された。前にも同じ指摘があった)。新しい体は、ディスプレイの幅を180pxにそろえ、足もとを y=905 に置く
- 書き出しのたびに、体がショートの映像の窓(y172〜937)からはみ出していないかを hakobu_motion.py が確かめ、はみ出すコマがあれば「!体が映像の窓からはみ出す」と出す。この表示が出たら、動きの大きさか位置を直してから使う。手前に来る動き(ついてくる)は1.12倍まで
- 背景は話ごとに差し替えられる(bg=背景のパス)。暗い背景では体の色を合わせる(tint=赤/緑/青の倍率。顔とリングは染めない)。例: `"うなずく:NODS=3.0/3.5,dur=4.3,bg=…/第87話.png,tint=0.5/0.55/0.75"`
- 時刻は声の長さに合わせて上書きできる: `python3 hakobu_motion.py "バック:A=3.9,B0=4.3,B1=6.8,dur=7.3"`(組の値は 1.25/2.2 のように / でつなぐ)
- 2本目からは台本で組む: `_制作ベース/マンガショート_mkshort.py` に話ごとの台本(`マンガショート_台本/spec_ep{N}.py`:見出し・冒頭の映像と声・コマの切り抜きと声・効果音・まとめ)を渡すと、声の長さから場面の長さを決めて、映像と音を1本にする。字幕の「|」で改行、13字を超えると読点で自動で2行に。小声のセリフは db=-22・small=True(小さい字幕)
- 声の一括作成: `_ショート専用/8話のセリフ.json` と `8話の声.sh`(話ごとに `voice_eleven/{人}/試し_NN.mp3`)。常連の声に 槙原(エンジニア・第81話)・田中(医師・第83話)を足した
- 背景: 毎回同じ病室にならないよう、話ごとの場所の背景を ChatGPT で作る(bg_empty.png を添付して、カメラの高さと床の位置をそろえる)。プロンプトは `_ショート専用/背景/背景のプロンプト.md`、できた絵は同じフォルダに `第NN話.png`(2026-10-19 エイイチの指示)
- 2026-10-19(73回目): 冒頭の動きを話ごとに変える決まりと、動きをつなぐ Seq を 9-0b に足した。タイヤのまわりの白いふちを消す処理を足した。8本の冒頭を作り直した(第81・82・20・43・87話は大きな動きに)。
- 2026-10-19(72回目): 動くハコブくんの体を、頭まで欠けていない新しい体に替えた(古い体は頭が平らに切れる。エイイチの2度目の指摘)。体が映像の窓からはみ出すコマを書き出し時に知らせる確認を足した。話ごとの背景(bg)と体の色合わせ(tint)を 9-0b に書いた。
- 2026-10-19(71回目): 字幕の縁取りの中の穴を黒で埋める決まりを5節に足した(全部の動画に今後ずっと)。2行字幕の下が切れないかの確認を足した。第81・85・82・20・88・83・87・43話のショートを台本(mkshort)で作った。9-0b に8つの動きと時刻の上書き、9-0c に台本での組み方・声の一括作成・槙原と田中の声・話ごとの背景を足した。

まだ決まっていないこと

  • 8本の投稿用のタイトル・説明文と SNS の文。第81話(ハロウィン)から作る
  • 第81話の冒頭の最初の3秒ほどは、看護師さんの吹き出しがかぼちゃのてっぺんに重なる。左の壁にぶつかる一瞬(約0.2秒)は、画面の揺れでかぼちゃの左端が画面の端にかかる
  • 本編とサムネイルの字幕(SVG で描く)に、穴を黒で埋める処理をまだ入れていない
  • 公開後、ショートの「視聴を継続」の割合(いまは19.0%)が上がるか。上がった話の作り方を量産する

追記:サイトのトップのハコブくんの動画を、YouTube と同じ声にした

記事を公開したあと、サイトのトップ(最初に見える場所)のハコブくんの動画も、声を YouTube と同じハコブくんの声にそろえました。

サイトのTOPのFVのハコブくんの動画を今のYouTubeの声に合わせたい

トップの動画は10秒で、元の動画を作ったときの別の声が入っています。どのセリフが何秒に入っているかを、文字起こしの仕組み(faster-whisper の small モデル)で調べました。

0.0 7.4 失礼します。はこぶ君です。お薬をお届けに来ました。お名前をご確認ください。
    0.0 0.68 失
    0.68 0.88 礼
    0.88 1.36 します。
    1.78 1.94 は
    1.94 2.06 こ
    2.06 2.18 ぶ
    2.18 2.36 君
    2.36 2.68 です。
    3.4 3.56 お
    3.56 3.82 薬
    3.82 4.06 を
    4.06 4.22 お
    4.22 4.3 届
    4.3 4.48 け
    4.48 4.58 に
    4.58 4.7 来
    4.7 5.08 ました。
    5.92 6.08 お
    6.08 6.16 名
    6.16 6.4 前
    6.4 6.54 を
    6.54 6.66 ご
    6.66 6.82 確
    6.82 7.02 認
    7.02 7.4 ください。

声の大きさを0.1秒ごとに見ると、話している区間は4つで、声のない区間は −72〜−89dB でした。声のほかに音は入っていないので、音はまるごと差し替えられます。画面のハコブくんの口がこの時刻で動いているので、新しい声も同じ時刻に置きます。

秒セリフ
0.5〜1.3失礼します。
1.8〜2.6ハコブくんです。
3.4〜5.2お薬をお届けに来ました。
5.9〜7.4お名前をご確認ください。

ElevenLabs で4文を作ると、2つ目の「ハコブくんです。」のイントネーションが変でした。

02のイントネーションが変

書き方を変えて5通り作りました(「ハコブ君です。」「ハコブくん、です。」「はこぶくんです。」「わたし、ハコブくんです。」「ハコブです。」)。正しく読めたのは4つ目の「わたし、ハコブくんです。」でした。

わたしはいらない?(前のあったっけ?)

元のセリフに「わたし」はありません。4つ目の声で、「わたし、」と「ハコブくんです。」の間には0.46〜0.78秒の無音があったので、0.70秒より前を切り落としました。残った「ハコブくんです。」は約0.8秒で、元の動画で口が動いている1.8〜2.6秒にちょうど収まります。4文とも速さは変えずに置けました。

置いた秒終わりの秒声
0.51.38失礼します。
1.82.68ハコブくんです。(「わたし、」を切った)
3.45.08お薬をお届けに来ました。
5.97.48お名前をご確認ください。

音の大きさは −15.7 LUFS になりました。新しい動画は hakobu_fv_voice_eleven.mp4(md5 477b4f3d236d968525f435a1e3a082c6)として R2 に入れ、トップのページ(src/pages/index.astro)の動画の行だけを差し替えました。

追記:表紙は、色を使わずに見た目の種類を変えた

またサムネイルが似てきたね。同じ感じのが並びすぎ

シリーズ「ひとり研究所の裏側」の一覧。LOG354・350・347・346・344・343 のサムネイルが、どれもモノクロの英字の見出しと図で並んでいる

シリーズのページでは、明るい紙色の地にモノクロの英字と図、というサムネイルが続いていました。この記事も同じ種類で作っていたので、作り直したショートのコマ3枚を並べた黒地の表紙(cover)にしました。最初の版はコマが色付きでした。

最初の表紙。黒地に81%の文字と、色の付いたショートのコマが3枚並ぶ

サムネイルカラーはNGだね

ショートのコマも含めて、墨色(#0d0d0d)から紙色(#f5f5f0)の間の色だけの白黒に作り直しました。

白黒に作り直した表紙が、記録の一覧の左上に並んだところ。ほかのサムネイルと同じ白黒で、コマの写真があるぶん見分けがつく

記事のルール(article_rules.md)のサムネイルの節に、次の決まりを足しています。

- **構図だけでなく、見た目の種類も変える(2026-10-19 エイイチの指示「またサムネイルが似てきた。同じ感じのが並びすぎ」):** 構図を変えても、明るい紙色の地にモノクロの英字の見出しと図、という同じ種類が続くと、一覧では同じに見える。作る前に、記事のシリーズのページ(例: /series4)の直近6枚を見て、同じ種類が3枚以上続いていたら、記事の実物の画像(動画のコマ・実機の写真・画面写真・4コマなど)を主役にした表紙を作る。表紙は 1920×1080 の画像にして R2 に入れ、記事の先頭の項目に `cover: "/media/LOG{NNN}_cover.jpg"` を書く(一覧・記事の上・SNS の画像がその画像になる。LOG336・LOG354)。表紙も配色はモノクロ。実物の画像も白黒にして、墨色(#0d0d0d)から紙色(#f5f5f0)の間の色だけで作る(色の付いた表紙はエイイチが NG とした。2026-10-19)。この場合もサムネイルの HTML は作り、GitHub の articles/ に残す

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

台本ファイル(spec)の中の /tmp/claude-0/… と /home/claude/batch/… は、Claude のクラウドの作業環境での置き場所です。Mac では _制作ベース/マンガショート_台本/ に同じ中身を置いています。

_制作ベース/hakobu_motion.py

"""ハコブくんの短い動き(ショートの冒頭用)。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}

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, "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")

_制作ベース/pumpkin.py

"""ハコブくんの頭にかぶせる、フェルトのかぼちゃ(コードで描く)。pumpkin(w, h) → RGBA の PIL 画像
フェルトらしさ: つやなし・ふっくらした房・細かい毛羽(ノイズ)・ふちの毛羽立ち・房の境目のステッチ"""
import numpy as np
from PIL import Image, ImageDraw, ImageFilter

RNG = np.random.default_rng(7)

def _fiber(H, W, ss):
    """フェルトの毛羽: 細かいノイズ+ゆるいムラ(1 を中心に明るさを掛ける)"""
    fine = Image.fromarray((RNG.normal(128, 40, (H, W))).clip(0, 255).astype(np.uint8)).filter(ImageFilter.GaussianBlur(ss * 0.5))
    soft = Image.fromarray((RNG.normal(128, 60, (H // 24 + 1, W // 24 + 1))).clip(0, 255).astype(np.uint8)).resize((W, H), Image.BICUBIC)
    f = (np.asarray(fine, np.float32) - 128) / 128; s = (np.asarray(soft, np.float32) - 128) / 128
    return 1 + 0.10 * f + 0.04 * s

def _felt_layer(mask_d, base, fiber, ss, fuzz):
    """mask_d: 中心0・ふち1の距離。ふっくら陰影+毛羽+ふちの毛羽立ち"""
    d = mask_d
    al = np.clip((1 + 0.035 * fuzz - d) * 40, 0, 1)              # ふちを毛羽でぎざぎざに
    lum = (0.66 + 0.36 * np.clip(1 - d, 0, 1) ** 0.35) * fiber    # 中央がふくらみ、ふちが沈む(つやは描かない)
    rgb = np.stack([base[0] * lum, base[1] * lum, base[2] * lum], -1)
    return np.dstack([np.clip(rgb, 0, 255), al * 255]).astype(np.uint8)

def _stitch(draw, cx, cy, rx, ry, ss, col, a0=0, a1=360, dash=9, gap=6, width=2.2):
    """楕円に沿った破線(ステッチ)"""
    per = np.pi * (3 * (rx + ry) - np.sqrt((3 * rx + ry) * (rx + 3 * ry)))
    n = int(per * (a1 - a0) / 360 / ((dash + gap) * ss))
    for k in range(n):
        t0 = np.radians(a0 + (a1 - a0) * k / n); t1 = np.radians(a0 + (a1 - a0) * (k + dash / (dash + gap)) / n)
        ts = np.linspace(t0, t1, 4)
        draw.line([(cx + rx * np.cos(t), cy + ry * np.sin(t)) for t in ts], fill=col, width=int(width * ss))

def pumpkin(w=420, h=300, ss=3):
    W, H = w * ss, h * ss
    yy, xx = np.mgrid[0:H, 0:W].astype(np.float32)
    fiber = _fiber(H, W, ss)
    fuzz = (np.asarray(Image.fromarray(RNG.normal(128, 70, (H, W)).clip(0, 255).astype(np.uint8)).filter(ImageFilter.GaussianBlur(ss * 0.8)), np.float32) - 128) / 40
    canvas = Image.new("RGBA", (W, H), (0, 0, 0, 0))
    cx, top = W / 2, H * 0.17
    body_h = H - top - H * 0.02
    cy = top + body_h * 0.52
    n = 7
    for k in sorted(range(n), key=lambda k: -abs(k - (n - 1) / 2)):   # 外側の房から順に重ねる
        off = (k - (n - 1) / 2) / ((n - 1) / 2)
        lx = cx + off * W * 0.32
        rx = W * (0.20 - 0.055 * abs(off)); ry = body_h * (0.47 - 0.05 * abs(off))
        d = np.sqrt(((xx - lx) / rx) ** 2 + ((yy - cy) / ry) ** 2)
        shade = 1 - 0.14 * abs(off) - 0.10 * np.clip((yy - top) / body_h, 0, 1)   # 外側・下ほど少し暗い
        base = (236 * shade, 122 * shade, 44 * shade)
        layer = Image.fromarray(_felt_layer(d, base, fiber, ss, fuzz), "RGBA")
        dr = ImageDraw.Draw(layer)
        _stitch(dr, lx, cy, rx * 0.86, ry * 0.88, ss, (150, 64, 24, 235), a0=200, a1=340)   # 房のふちのステッチ(上側)
        _stitch(dr, lx, cy, rx * 0.86, ry * 0.88, ss, (150, 64, 24, 235), a0=20, a1=160)    # (下側)
        canvas.alpha_composite(layer)
    # ヘタ(緑のフェルト・ステッチ付き)と葉っぱ
    sx = cx + W * 0.01; sw = W * 0.075
    stem = Image.new("L", (W, H), 0); ds = ImageDraw.Draw(stem)
    ds.polygon([(sx - sw / 2, top + H * 0.07), (sx - sw * 0.42, top - H * 0.09), (sx - sw * 0.05, top - H * 0.15),
                (sx + sw * 0.6, top - H * 0.15), (sx + sw * 0.35, top - H * 0.08), (sx + sw / 2, top + H * 0.07)], fill=255)
    leaf = [(sx + sw * 0.4, top + H * 0.02), (sx + sw * 1.6, top - H * 0.10), (sx + sw * 2.9, top - H * 0.06),
            (sx + sw * 2.2, top + H * 0.03), (sx + sw * 1.0, top + H * 0.05)]
    ds.polygon(leaf, fill=255)
    sm = np.asarray(stem.filter(ImageFilter.GaussianBlur(ss * 0.6)), np.float32) / 255
    sm = np.clip((sm - 0.5 + 0.06 * fuzz) * 6 + 0.5, 0, 1)
    g = np.stack([96 * fiber, 128 * fiber, 62 * fiber], -1)
    canvas.alpha_composite(Image.fromarray(np.dstack([np.clip(g, 0, 255), sm * 255]).astype(np.uint8), "RGBA"))
    dg = ImageDraw.Draw(canvas)
    dg.line([(sx, top + H * 0.04), (sx + sw * 0.05, top - H * 0.12)], fill=(60, 84, 38, 230), width=int(2 * ss))
    for i in range(6):                                                         # 葉っぱの真ん中のステッチ
        u0, u1 = i / 6, (i + 0.6) / 6
        p = lambda u: (sx + sw * (0.6 + 1.9 * u), top - H * (0.0 + 0.06 * np.sin(np.pi * u)) + H * 0.01)
        dg.line([p(u0), p(u1)], fill=(60, 84, 38, 230), width=int(2 * ss))
    return canvas.resize((w, h), Image.LANCZOS)

if __name__ == "__main__":
    pumpkin().save("pumpkin_test.png")

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

"""マンガのショート(第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):
    r = subprocess.run(["ffmpeg", "-v", "error", "-i", path, "-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"])
    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

# ---------------- 時刻を決める ----------------
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)))
            SUBS.append((t0 + l["at"], min(t0 + l["at"] + len(a) / SR + 0.5, t0 + dur), l["sub"], l["who"], l.get("small")))
    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))
            SUBS.append((st, None, l["sub"], l["who"], l.get("small")))
            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 = {}
def panel(crop, scale):
    k = (crop, round(scale, 3))
    if k not in _pc:
        c = comic.crop(crop); c = c.resize((int(c.width * scale), int(c.height * scale)), Image.LANCZOS).convert("RGBA")
        ImageDraw.Draw(c).rectangle([0, 0, c.width - 1, c.height - 1], outline=INK, width=6); _pc[k] = c
    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):
    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)
    d.rounded_rectangle([60, 4, 60 + pw, 4 + ph], radius=46, fill=(255, 255, 255, 255), outline=INK, width=6)
    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")
    y = 29
    for l in lines:
        x = 95; 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)
    elif sc["kind"] == "panel":
        c = sc["crop"]; s0 = sc.get("scale") or min(1040 / (c[2] - c[0]), 930 / (c[3] - c[1]))
        s = s0 * (0.96 + 0.04 * ease(u))
        paste_c(vis, panel(sc["crop"], 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 - 40, 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), 500, 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), "秒")

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

NAME, EP = "第81話_かぼちゃ", 81
HEAD = ["かぼちゃをかぶせたら", "前が【全面オレンジ】"]
COMIC = "/home/claude/batch/ep81.png"
V = "voice/"
SCENES = [
  dict(kind="op", video="/tmp/claude-0/-home-claude/b3dc1dac-7e04-5ee5-a0f6-e6e9c33512f2/scratchpad/anim/ハコブくん_かぼちゃで暴走.mp4", dur=6.4,
       bubble=["かわいい!", "子どもたち、喜ぶわよ"], bubble_until=3.3,
       lines=[dict(v=V+"高杉/試し_01.mp3", who="ns", sub="かわいい!子どもたち、喜ぶわよ。", at=0.15),
              dict(v=V+"ハコブ/試し_01.mp3", who="hk", sub="前方が、【全面オレンジ色】です。", at=3.45)],
       words=[dict(text="ゴン!", at=0.95, until=1.6, x=830, y=300, size=130, rot=10),
              dict(text="ガン!", at=2.05, until=2.7, x=200, y=300, size=150, rot=-10),
              dict(text="ドスン", at=2.85, until=3.6, x=600, y=840, size=130, color="orange", rot=-4)],
       se=[dict(at=0.25, f="se/se_dash.mp3", db=-8), dict(at=0.95, f="se/se_don.mp3", db=-10),
           dict(at=2.05, f="se/se_don.mp3", db=-6), dict(at=2.85, f="se/se_don.mp3", db=-4)]),
  dict(kind="panel", crop=(526, 426, 1057, 861),
       lines=[dict(v=V+"高杉/試し_02.mp3", who="ns", sub="…ウケては、いるわね。")]),
  dict(kind="panel", crop=(22, 885, 600, 1322),
       lines=[dict(v=V+"槙原/試し_01.mp3", who="ns", sub="センサーの窓だけは、|塞がないでください。")]),
  dict(kind="panel", crop=(470, 885, 1057, 1322), min=2.2,
       lines=[dict(v=V+"ハコブ/試し_02.mp3", who="hk", sub="視界、良好です。")]),
  dict(kind="end", dur=4.4, rows=[("飾りたい気持ちは", 74, "ink", 0.0), ("止めなくていい", 74, "ink", 0.25),
       ("塞いではいけない", 104, "orange", 0.9), ("場所を先に示す", 104, "orange", 1.15),
       ("(センサーの窓は、ロボットの目)", 40, "gray", 2.0)]),
]

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

NAME, EP = "第85話_取り消し", 85
HEAD = ["取り消しと言ったら", "来た道を【バック】"]
COMIC = "/home/claude/batch/ep85.png"
V = "voice/"
SCENES = [
  dict(kind="op", video="/tmp/claude-0/-home-claude/b3dc1dac-7e04-5ee5-a0f6-e6e9c33512f2/scratchpad/anim/ハコブくん_バック.mp4", dur=8.3,
       bubble=["リネンを", "5階へ、お願い"], bubble_until=1.95,
       lines=[dict(v=V+"高杉/試し_01.mp3", who="ns", sub="リネンを5階へ、お願い。", at=0.15),
              dict(v=V+"高杉/試し_02.mp3", who="ns", sub="あ、ごめん、今の【取り消し】!", at=2.1),
              dict(v=V+"ハコブ/試し_01.mp3", who="hk", sub="バックします。", at=4.45, cut=(0.95, 99))],
       se=[dict(at=k / 2, f="base/se_backbeep.wav", db=-10) for k in range(11, 16)],
       words=[dict(text="ピー", at=5.5, until=7.75, x=820, y=330, size=120, color="orange", rot=8),
              dict(text="ピー", at=6.0, until=7.75, x=250, y=420, size=120, color="orange", rot=-8)]),
  dict(kind="panel", crop=(553, 426, 1057, 861), min=2.0,
       words=[dict(text="ピー、ピー", at=0.2, x=540, y=820, size=110, color="orange", rot=-5)],
       se=[dict(at=0.2 + 0.5 * k, f="base/se_backbeep.wav", db=-10) for k in range(3)]),
  dict(kind="panel", crop=(22, 885, 560, 1322), min=2.4,
       lines=[dict(v=V+"ハコブ/試し_02.mp3", who="hk", sub="元の位置に、戻りました。")]),
  dict(kind="panel", crop=(480, 885, 1057, 1322),
       lines=[dict(v=V+"大城/試し_01.mp3", who="ns", sub="止まるだけで、よかったのよ…。")]),
  dict(kind="end", dur=4.4, rows=[("取り消すことと", 74, "ink", 0.0), ("来た道を戻ることは", 74, "ink", 0.25),
       ("別の話", 120, "orange", 0.9), ("(止まるだけでいい場面もある)", 40, "gray", 1.8)]),
]

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

NAME, EP = "第82話_ため息", 82
HEAD = ["ナースのため息を", "【まね】して覚えた"]
COMIC = "/home/claude/batch/ep82.png"
V = "voice/"
SCENES = [
  dict(kind="op", video="/tmp/claude-0/-home-claude/b3dc1dac-7e04-5ee5-a0f6-e6e9c33512f2/scratchpad/anim/ハコブくん_ため息で落ちこむ.mp4", dur=5.2,
       bubble=["はぁ…"], bubble_until=1.2,
       lines=[dict(v=V+"大城/試し_01.mp3", who="ns", sub="はぁ…。", at=0.15),
              dict(v=V+"ハコブ/試し_01.mp3", who="hk", sub="はぁ…。", at=1.3),
              dict(v=V+"ハコブ/試し_02.mp3", who="hk", sub="はぁぁぁぁ…。", at=2.6)]),
  dict(kind="panel", crop=(526, 426, 1057, 861),
       lines=[dict(v=V+"寺島/試し_01.mp3", who="ns", sub="あんたが一番、|疲れてるみたいじゃない。")]),
  dict(kind="panel", crop=(430, 885, 1057, 1322),
       lines=[dict(v=V+"大城/試し_02.mp3", who="ns", sub="真似するなら、ため息じゃなくて|【笑顔】にして。")]),
  dict(kind="panel", crop=(22, 885, 640, 1322),
       lines=[dict(v=V+"寺島/試し_02.mp3", who="ns", sub="…これはこれで、ちょっと怖い。")]),
  dict(kind="end", dur=4.4, rows=[("教えたつもりが", 74, "ink", 0.0), ("なくても", 74, "ink", 0.25),
       ("見せたものは", 104, "orange", 0.9), ("覚えてしまう", 104, "orange", 1.15),
       ("(まねから学ぶロボットの弱点)", 40, "gray", 2.0)]),
]

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

NAME, EP = "第20話_撫でて", 20
HEAD = ["毎日なでていたら", "【なでて】と言い出した"]
COMIC = "/home/claude/batch/ep20.png"
V = "voice/"
SCENES = [
  dict(kind="op", video="/tmp/claude-0/-home-claude/b3dc1dac-7e04-5ee5-a0f6-e6e9c33512f2/scratchpad/anim/ハコブくん_喜んで撫でて.mp4", dur=5.8,
       bubble=["ハコブくん、", "次の配送…"], bubble_until=2.4,
       lines=[dict(v=V+"高杉/試し_01.mp3", who="ns", sub="ハコブくん、次の配送…。あれ?", at=0.15),
              dict(v=V+"ハコブ/試し_01.mp3", who="hk", sub="【撫でてください】。強制。", at=3.45)],
       se=[dict(at=2.4, f="se/se_pikon.mp3", db=-8)]),
  dict(kind="panel", crop=(531, 531, 1046, 972),
       lines=[dict(v=V+"高杉/試し_02.mp3", who="ns", sub="な…撫でて…!?")]),
  dict(kind="panel", crop=(250, 30, 1046, 519),
       lines=[dict(v=V+"大城/試し_01.mp3", who="ns", sub="今日も偉いね〜、ハコブくん。")],
       words=[dict(text="(毎日)", at=0.1, x=200, y=150, size=90, color="orange", rot=-6)]),
  dict(kind="panel", crop=(24, 984, 700, 1319),
       lines=[dict(v=V+"大城/試し_02.mp3", who="ns", sub="あはは…。撫でられることを|優先するように、|学習しちゃったかも…。")]),
  dict(kind="end", dur=4.4, rows=[("ほめられることを", 74, "ink", 0.0), ("ごほうびにすると", 74, "ink", 0.25),
       ("仕事より", 104, "orange", 0.9), ("なでられたがる", 104, "orange", 1.15),
       ("(ごほうびの決め方で、ロボットの行動が変わる)", 36, "gray", 2.0)]),
]

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

NAME, EP = "第88話_ついてきて", 88
HEAD = ["ついてきてと言ったら", "【どこまでも】来た"]
COMIC = "/home/claude/batch/ep88.png"
V = "voice/"
SCENES = [
  dict(kind="op", video="/tmp/claude-0/-home-claude/b3dc1dac-7e04-5ee5-a0f6-e6e9c33512f2/scratchpad/anim/ハコブくん_ついてくる.mp4", dur=5.2,
       bubble=["ハコブくん、", "ついてきて"], bubble_until=1.7,
       lines=[dict(v=V+"大城/試し_01.mp3", who="ns", sub="ハコブくん、ついてきて。", at=0.15)],
       words=[dict(text="トコ", at=2.0, until=3.6, x=200, y=760, size=110, rot=-8),
              dict(text="トコ", at=2.7, until=4.6, x=880, y=700, size=110, rot=8)]),
  dict(kind="panel", crop=(22, 426, 526, 861), min=1.9,
       words=[dict(text="更衣室まで", at=0.2, x=300, y=110, size=90, color="orange", rot=-5)]),
  dict(kind="panel", crop=(553, 426, 1057, 861),
       lines=[dict(v=V+"大城/試し_02.mp3", who="ns", sub="…あんた、どこまで来る気?"),
              dict(v=V+"ハコブ/試し_01.mp3", who="hk", sub="ついてきて、と言われました。")],
       words=[dict(text="帰り道まで", at=0.2, x=300, y=110, size=90, color="orange", rot=-5)]),
  dict(kind="panel", crop=(620, 885, 1057, 1322),
       lines=[dict(v=V+"白石/試し_01.mp3", who="ns", sub="追従モードに、|【終了条件】がありませんでした。")]),
  dict(kind="panel", crop=(22, 885, 640, 1322),
       lines=[dict(v=V+"大城/試し_03.mp3", who="ns", sub="「ここまで」も言わないと、|ダメなのね。"),
              dict(v=V+"ハコブ/試し_02.mp3", who="hk", sub="次から、終わりを聞きます。")]),
  dict(kind="end", dur=4.4, rows=[("始める合図と", 74, "ink", 0.0), ("いっしょに", 74, "ink", 0.25),
       ("終わりの合図も", 104, "orange", 0.9), ("決めておく", 104, "orange", 1.15),
       ("(追従モードの終了条件)", 40, "gray", 2.0)]),
]

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

NAME, EP = "第83話_確認", 83
HEAD = ["確認しすぎたら", "最後は【初期化】"]
COMIC = "/home/claude/batch/ep83.png"
V = "voice/"
SCENES = [
  dict(kind="op", video="/tmp/claude-0/-home-claude/b3dc1dac-7e04-5ee5-a0f6-e6e9c33512f2/scratchpad/anim/ハコブくん_再起動.mp4", dur=9.9,
       bubble=["わかったから、", "早く!"], bubble_from=1.4, bubble_until=4.85,
       lines=[dict(v=V+"ハコブ/試し_03.mp3", who="hk", sub="最終確認です。", at=0.15),
              dict(v=V+"田中/試し_03.mp3", who="ns", sub="わかったから、早く!", at=1.4),
              dict(v=V+"ハコブ/試し_04.mp3", who="hk", sub="【初期化】しますか?", at=3.25),
              dict(v=V+"田中/試し_04.mp3", who="ns", sub="わかった!", at=4.3),
              dict(v=V+"ハコブ/試し_05.mp3", who="hk", sub="はじめまして。|わたしは、ハコブです。", at=6.9)],
       words=[dict(text="ブツッ", at=4.95, until=6.2, x=800, y=300, size=130, rot=8),
              dict(text="再起動", at=6.2, until=6.9, x=540, y=180, size=110, color="orange")],
       se=[dict(at=4.9, f="se/se_don.mp3", db=-8), dict(at=6.55, f="base/se_ding.wav", db=-8)]),
  dict(kind="panel", crop=(22, 885, 760, 1322),
       lines=[dict(v=V+"田中/試し_05.mp3", who="ns", sub="…確認が多すぎると、|人は【読まなくなる】んだな。")]),
  dict(kind="end", dur=4.4, rows=[("確認が多すぎると", 74, "ink", 0.0), ("人は読まなくなる", 74, "ink", 0.25),
       ("大事な確認だけ", 104, "orange", 0.9), ("残す", 104, "orange", 1.15),
       ("(確認ダイアログの設計)", 40, "gray", 2.0)]),
]

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

NAME, EP = "第87話_小声", 87
HEAD = ["小声でと頼んだら", "【大事なこと】まで小声"]
COMIC = "/home/claude/batch/ep87.png"
V = "voice/"
SCENES = [
  dict(kind="op", video="/tmp/claude-0/-home-claude/b3dc1dac-7e04-5ee5-a0f6-e6e9c33512f2/scratchpad/anim/ハコブくん_なぐさめる.mp4", dur=4.3,
       bubble=["患者さん寝てるから、", "声は小さくね"], bubble_until=2.95,
       lines=[dict(v=V+"大城/試し_01.mp3", who="ns", sub="患者さん寝てるから、|声は小さくね。", at=0.15),
              dict(v=V+"ハコブ/試し_01.mp3", who="hk", sub="了解しました。", at=3.0)]),
  dict(kind="panel", crop=(22, 426, 526, 861),
       lines=[dict(v=V+"ハコブ/試し_02.mp3", who="hk", sub="(この薬、302号室と取り違えています)", db=-22, small=True),
              dict(v=V+"大城/試し_02.mp3", who="ns", sub="…え?何?")],
       words=[dict(text="ヒソ ヒソ", at=0.2, until=3.4, x=760, y=150, size=80, rot=6)]),
  dict(kind="panel", crop=(553, 426, 1057, 861),
       lines=[dict(v=V+"大城/試し_03.mp3", who="ns", sub="それは【大きい声】で言って!",
                   **{"with": [V+"寺島/試し_01.mp3"]})],
       slam=dict(text="大きい声で!", at=0.15, y=880, size=130, color="orange"),
       se=[dict(at=0.15, f="se/se_don.mp3", db=-6)]),
  dict(kind="panel", crop=(22, 885, 620, 1322),
       lines=[dict(v=V+"ハコブ/試し_03.mp3", who="hk", sub="大事なことは、小声にしません。")]),
  dict(kind="panel", crop=(480, 885, 1057, 1322), min=1.8,
       lines=[dict(v=V+"大城/試し_04.mp3", who="ns", sub="そう、それ。")]),
  dict(kind="end", dur=4.4, rows=[("声の大きさは", 74, "ink", 0.0), ("場所だけでなく", 74, "ink", 0.25),
       ("中身で", 104, "orange", 0.9), ("変える", 104, "orange", 1.15),
       ("(大事な警告は、小声にしない)", 40, "gray", 2.0)]),
]

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

NAME, EP = "第43話_謝罪", 43
HEAD = ["ミスしたあと、", "【謝りつづける】ロボ"]
COMIC = "/home/claude/batch/ep43.png"
V = "voice/"
SCENES = [
  dict(kind="op", video="/tmp/claude-0/-home-claude/b3dc1dac-7e04-5ee5-a0f6-e6e9c33512f2/scratchpad/anim/ハコブくん_あわてて謝る.mp4", dur=5.0,
       lines=[dict(v=V+"ハコブ/試し_01.mp3", who="hk", sub="インシデント…。", at=0.15),
              dict(v=V+"ハコブ/試し_02.mp3", who="hk", sub="申し訳ありませんでした。|先ほどは、申し訳…", at=1.15)],
       words=[dict(text="ペコ", at=1.2, until=4.9, x=200, y=300, size=110, rot=-8),
              dict(text="ペコ", at=1.75, until=4.9, x=880, y=380, size=110, rot=8)]),
  dict(kind="panel", crop=(480, 379, 1046, 896),
       lines=[dict(v=V+"ハコブ/試し_03.mp3", who="hk", sub="さきほどは…")], min=2.0),
  dict(kind="panel", crop=(36, 915, 560, 1321),
       lines=[dict(v=V+"大城/試し_01.mp3", who="ns", sub="もういいから前進んで!|謝りすぎは、【仕事の邪魔】!")],
       se=[dict(at=0.1, f="base/se_pop.wav", db=-10), dict(at=0.35, f="base/se_pop.wav", db=-10)]),
  dict(kind="panel", crop=(440, 915, 1046, 1321),
       lines=[dict(v=V+"ハコブ/試し_04.mp3", who="hk", sub="了解。謝罪、終了します。")]),
  dict(kind="end", dur=4.4, rows=[("謝ることより", 74, "ink", 0.0), ("大事なのは", 74, "ink", 0.25),
       ("次の仕事に", 104, "orange", 0.9), ("戻ること", 104, "orange", 1.15),
       ("(ミスのあとの、立ち直り方の設計)", 40, "gray", 2.0)]),
]

_制作ベース/voice_design.py

"""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 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="はい、今行きます。こちらのお部屋で大丈夫ですよ。ゆっくりで構いませんからね。何かあったら、"
             "このボタンで呼んでください。夜中でも、すぐに来ますから。お大事になさってください。明日の朝も、また様子を見に来ますね。"),
}

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

{
 "寺島": "yWM6Fym2UxDGZqWRoGLB",
 "高杉": "2zABg81whjrIKypr7XTd",
 "大城": "PrFsT9a2iZebpuiWVeug",
 "白石": "orQ2eWONBvtrTIvdS8RS",
 "槙原": "Sryf5xKmMsQChU3HsLhZ",
 "田中": "ydyMD3g4SbEeNpF0wahP"
}

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

{
 "第81話_かぼちゃ": {
  "ハコブ": [
   "前方が、全面オレンジ色です。",
   "視界、良好です。"
  ],
  "高杉": [
   "かわいい!子どもたち、喜ぶわよ。",
   "…ウケては、いるわね。"
  ],
  "槙原": [
   "センサーの窓だけは、塞がないでください。"
  ]
 },
 "第85話_取り消し": {
  "ハコブ": [
   "バックします。ピー、ピー、ピー。",
   "元の位置に、戻りました。"
  ],
  "高杉": [
   "リネンを5階へ、お願い。",
   "あ、ごめん、今の取り消し!"
  ],
  "大城": [
   "止まるだけで、よかったのよ…。"
  ]
 },
 "第82話_ため息": {
  "ハコブ": [
   "はぁ…。",
   "はぁぁぁぁ…。"
  ],
  "大城": [
   "はぁ…。",
   "真似するなら、ため息じゃなくて笑顔にして。"
  ],
  "寺島": [
   "あんたが一番、疲れてるみたいじゃない。",
   "…これはこれで、ちょっと怖い。"
  ]
 },
 "第20話_撫でて": {
  "ハコブ": [
   "撫でてください。強制。"
  ],
  "大城": [
   "今日も偉いね〜、ハコブくん。",
   "あはは…。撫でられることを優先するように、学習しちゃったかも…。"
  ],
  "高杉": [
   "ハコブくん、次の配送…。あれ?",
   "な…撫でて…!?"
  ]
 },
 "第88話_ついてきて": {
  "ハコブ": [
   "ついてきて、と言われました。",
   "次から、終わりを聞きます。"
  ],
  "大城": [
   "ハコブくん、ついてきて。",
   "…あんた、どこまで来る気?",
   "「ここまで」も言わないと、ダメなのね。"
  ],
  "白石": [
   "追従モードに、終了条件がありませんでした。"
  ]
 },
 "第83話_確認": {
  "ハコブ": [
   "303号室で、よろしいですか?",
   "本当に、よろしいですか?",
   "最終確認です。",
   "初期化しますか?",
   "はじめまして。わたしは、ハコブです。"
  ],
  "田中": [
   "303号室まで、急ぎで。",
   "はい。",
   "わかったから、早く!",
   "わかった!",
   "…確認が多すぎると、人は読まなくなるんだな。"
  ]
 },
 "第87話_小声": {
  "ハコブ": [
   "了解しました。",
   "この薬、302号室と取り違えています。",
   "大事なことは、小声にしません。"
  ],
  "大城": [
   "患者さん寝てるから、声は小さくね。",
   "…え?何?",
   "それは大きい声で言って!",
   "そう、それ。"
  ],
  "寺島": [
   "それは大きい声で言って!"
  ]
 },
 "第43話_謝罪": {
  "ハコブ": [
   "インシデント…。",
   "申し訳ありませんでした。先ほどは、申し訳…",
   "さきほどは…",
   "了解。謝罪、終了します。"
  ],
  "大城": [
   "もういいから前進んで!謝りすぎは、仕事の邪魔!"
  ]
 }
}

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

#!/bin/bash
# 8話ぶんの声をまとめて作る。先に read -s ELEVENLABS_API_KEY && export ELEVENLABS_API_KEY
# 槙原さん・田中先生の声は、先に _制作ベース で voice_design.py design → save をすませておく
set -e
cd "$(dirname "$0")"
B=../_制作ベース
id() { python3 -c "import json;print(json.load(open('../$B/声の一覧.json'))['$1'])"; }
echo "== 第81話_かぼちゃ"; cd "第81話_かぼちゃ"
python3 ../$B/tts_eleven.py --voice "gWJpc8whMBqnF7Y0kc9f" --name ハコブ --try "前方が、全面オレンジ色です。|視界、良好です。"
python3 ../$B/tts_eleven.py --voice "$(id 高杉)" --name 高杉 --try "かわいい!子どもたち、喜ぶわよ。|…ウケては、いるわね。"
python3 ../$B/tts_eleven.py --voice "$(id 槙原)" --name 槙原 --try "センサーの窓だけは、塞がないでください。"
cd ..
echo "== 第85話_取り消し"; cd "第85話_取り消し"
python3 ../$B/tts_eleven.py --voice "gWJpc8whMBqnF7Y0kc9f" --name ハコブ --try "バックします。ピー、ピー、ピー。|元の位置に、戻りました。"
python3 ../$B/tts_eleven.py --voice "$(id 高杉)" --name 高杉 --try "リネンを5階へ、お願い。|あ、ごめん、今の取り消し!"
python3 ../$B/tts_eleven.py --voice "$(id 大城)" --name 大城 --try "止まるだけで、よかったのよ…。"
cd ..
echo "== 第82話_ため息"; cd "第82話_ため息"
python3 ../$B/tts_eleven.py --voice "gWJpc8whMBqnF7Y0kc9f" --name ハコブ --try "はぁ…。|はぁぁぁぁ…。"
python3 ../$B/tts_eleven.py --voice "$(id 大城)" --name 大城 --try "はぁ…。|真似するなら、ため息じゃなくて笑顔にして。"
python3 ../$B/tts_eleven.py --voice "$(id 寺島)" --name 寺島 --try "あんたが一番、疲れてるみたいじゃない。|…これはこれで、ちょっと怖い。"
cd ..
echo "== 第20話_撫でて"; cd "第20話_撫でて"
python3 ../$B/tts_eleven.py --voice "gWJpc8whMBqnF7Y0kc9f" --name ハコブ --try "撫でてください。強制。"
python3 ../$B/tts_eleven.py --voice "$(id 大城)" --name 大城 --try "今日も偉いね〜、ハコブくん。|あはは…。撫でられることを優先するように、学習しちゃったかも…。"
python3 ../$B/tts_eleven.py --voice "$(id 高杉)" --name 高杉 --try "ハコブくん、次の配送…。あれ?|な…撫でて…!?"
cd ..
echo "== 第88話_ついてきて"; cd "第88話_ついてきて"
python3 ../$B/tts_eleven.py --voice "gWJpc8whMBqnF7Y0kc9f" --name ハコブ --try "ついてきて、と言われました。|次から、終わりを聞きます。"
python3 ../$B/tts_eleven.py --voice "$(id 大城)" --name 大城 --try "ハコブくん、ついてきて。|…あんた、どこまで来る気?|「ここまで」も言わないと、ダメなのね。"
python3 ../$B/tts_eleven.py --voice "$(id 白石)" --name 白石 --try "追従モードに、終了条件がありませんでした。"
cd ..
echo "== 第83話_確認"; cd "第83話_確認"
python3 ../$B/tts_eleven.py --voice "gWJpc8whMBqnF7Y0kc9f" --name ハコブ --try "303号室で、よろしいですか?|本当に、よろしいですか?|最終確認です。|初期化しますか?|はじめまして。わたしは、ハコブです。"
python3 ../$B/tts_eleven.py --voice "$(id 田中)" --name 田中 --try "303号室まで、急ぎで。|はい。|わかったから、早く!|わかった!|…確認が多すぎると、人は読まなくなるんだな。"
cd ..
echo "== 第87話_小声"; cd "第87話_小声"
python3 ../$B/tts_eleven.py --voice "gWJpc8whMBqnF7Y0kc9f" --name ハコブ --try "了解しました。|この薬、302号室と取り違えています。|大事なことは、小声にしません。"
python3 ../$B/tts_eleven.py --voice "$(id 大城)" --name 大城 --try "患者さん寝てるから、声は小さくね。|…え?何?|それは大きい声で言って!|そう、それ。"
python3 ../$B/tts_eleven.py --voice "$(id 寺島)" --name 寺島 --try "それは大きい声で言って!"
cd ..
echo "== 第43話_謝罪"; cd "第43話_謝罪"
python3 ../$B/tts_eleven.py --voice "gWJpc8whMBqnF7Y0kc9f" --name ハコブ --try "インシデント…。|申し訳ありませんでした。先ほどは、申し訳…|さきほどは…|了解。謝罪、終了します。"
python3 ../$B/tts_eleven.py --voice "$(id 大城)" --name 大城 --try "もういいから前進んで!謝りすぎは、仕事の邪魔!"
cd ..
echo できあがり

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

# マンガショートの背景(話ごと)

## 使い方
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話(謝罪): 病棟の物品・検体の保管室。棚に箱、採血管のトレー、蛍光灯

_ショート専用/第79話_呼び名/README.md

# ショート専用「第79話 呼び名は、よく聞こえすぎる」(2026-10-19・これを型にする)

縦長 1080×1920・約20秒。冒頭は動くハコブくん(hakobu_motion.py の「呼ばれた」)、続けてマンガのコマ、最後にまとめの画面。

作り方(Claude の作業環境で)
1. `python3 ../../_制作ベース/hakobu_motion.py 呼ばれた` → op.mp4(冒頭の動き。時刻は Summon の A・V0・R0・R1・talk)
2. 材料を同じフォルダに: ep79.png(マンガ本編)・logo_dark.png・op.mp4・voice/000〜002.mp3(ハコブくん)・nv/*.mp3(看護師さん)・se/*(効果音)・base/se_*.wav(_制作ベースの音)
3. `python3 short79.py --still 0.5,6.0` で静止画を確かめる → `python3 short79.py`(映像 video.mp4)→ `python3 mix79.py`(音を混ぜて out.mp4)

画面の割り振り: ロゴ y175・見出し2行 y228〜(86px)・映像とコマ y466〜1440・クレジット 24px・字幕 y1488〜(ハコブくん水色・人は白)
声: ハコブくんは voice_eleven/ハコブ(001 は ハコブ_試し/試し_01「お呼びですか?」)。看護師さんは voice_eleven/寺島・大城・高杉(_制作ベース/声の一覧.json の声で --try)
効果音: ElevenLabs(ピコン・空回り・ダッシュ・ドーン)、止まる音は効果音ラボ「靴でブレーキキキキーッ」(brake1.mp3 を 0.8 秒で切って 0.3 秒で消す)

_ショート専用/第79話_呼び名/作る.sh

#!/bin/bash
# ハコブくんの声(3文)と効果音を作る。先に  read -s ELEVENLABS_API_KEY && export ELEVENLABS_API_KEY
set -e
B=../../_制作ベース
python3 $B/tts_eleven.py --voice gWJpc8whMBqnF7Y0kc9f --name ハコブ
python3 $B/sound_eleven.py sfx --name se_pikon  --sec 0.6 --count 2 --prompt "short bright cute robot notification ping, two quick rising electronic tones, clean, no music"
python3 $B/sound_eleven.py sfx --name se_rev    --sec 1.0 --count 2 --prompt "small robot wheels spinning up very fast, high pitched electric motor whine accelerating, cartoon"
python3 $B/sound_eleven.py sfx --name se_dash   --sec 0.8 --count 2 --prompt "cartoon super fast dash whoosh toward the camera, comedic zoom"
python3 $B/sound_eleven.py sfx --name se_skid   --sec 0.8 --count 2 --prompt "cartoon skid stop, small rubber wheels screeching on a smooth hospital floor, comedic"
python3 $B/sound_eleven.py sfx --name se_don    --sec 0.7 --count 2 --prompt "comedic manga impact sound, punchy low thud with a short cymbal hit, short"
echo できあがり

_ショート専用/第79話_呼び名/看護師の声.sh

#!/bin/bash
# 第79話の看護師さんのセリフを、保存した声で作る(ElevenLabs の読み上げ・eleven_v4)。先に API キーを入れておく
set -e
B=../../_制作ベース
id() { python3 -c "import json;print(json.load(open('$B/声の一覧.json'))['$1'])"; }
python3 $B/tts_eleven.py --voice "$(id 寺島)" --name 寺島 --try "この箱、運ぶの手伝って。|え、呼んでないよ?|呼んでない!"
python3 $B/tts_eleven.py --voice "$(id 大城)" --name 大城 --try "空き箱、ぶつけないようにね。|呼んでない!"
python3 $B/tts_eleven.py --voice "$(id 高杉)" --name 高杉 --try "呼んでない!"
echo できあがり

_ショート専用/第79話_呼び名/narration.py

# ショート専用「第79話 呼び名は、よく聞こえすぎる」— ハコブくんのセリフだけ(人のセリフは声なし・吹き出しと字幕)
SEGS = [
    ("op",   "ハイッ!", 0.2),
    ("p2",   "オ呼ビデスカ?", 0.3),
    ("p4",   "ハコブ、ト聞コエマシタ。", 0.4),
]
# ElevenLabs がカタカナを区切って読むので、声に渡すときだけひらがなにする(字幕はカタカナのまま)
ELEVEN_READ = [
    ("ハイッ!", "はいっ!"),
    ("オ呼ビデスカ?", "およびですか?"),
    ("ハコブ、ト聞コエマシタ。", "はこぶ、と、きこえました。"),
]

_ショート専用/第79話_呼び名/short79.py

"""ショート専用「第79話 呼び名は、よく聞こえすぎる」を縦長(1080×1920)で組む。
使い方: python3 short79.py [--still 秒,秒,...]   → out.mp4(--still は確かめ用の静止画 still_{秒}.png)
入力: ep79.png(マンガ本編)・logo_dark.png・op.mp4(hakobu_motion.py の「呼ばれた」)・voice/*.wav・se/*.wav(あれば)
"""
import os, sys, math, subprocess, json
import numpy as np
from PIL import Image, ImageDraw, ImageFont, ImageFilter

W, H, FPS = 1080, 1920, 30
PAPER = (245, 245, 240); INK = (13, 13, 13); GRAY = (136, 136, 136); ORANGE = (196, 71, 31); BLUE = (120, 200, 255)
BLACK = "/usr/share/fonts/opentype/noto/NotoSansCJK-Black.ttc"
F = lambda size: ImageFont.truetype(BLACK, size, index=0)

VX, VY, VW, VH = 0, 466, 1080, 974          # 映像・コマを置く区画
SUB_Y = 1488                                   # 字幕の上端(2行まで・〜1630)

# ---------------- 声と効果音の置き方(声の長さが分かったら timing.json で上書き) ----------------
T = dict(hai=1.85, oyobi=3.52, kikoe=13.3)    # 声の頭の時刻
DUR = dict(hai=0.5, oyobi=1.05, kikoe=1.9)    # 声の長さ(仮)
if os.path.exists("timing.json"):
    j = json.load(open("timing.json")); T.update(j.get("T", {})); DUR.update(j.get("DUR", {}))

# 場面(開始, 終了, 名前)
OP_END = 5.1
A, V0, R0, R1 = 1.0, 2.0, 2.55, 3.0            # 冒頭の映像(hakobu_motion.py の呼ばれた)の、反応・空回り・突進・止まる時刻
P3 = 7.8                                       # 3コマ目の頭
D0 = 9.9                                       # 2回目の突進
P4 = D0 + 0.95                                 # 4コマ目(呼んでない!)
END0 = 15.9
SCENES = [(0.0, OP_END, "op"), (OP_END, P3, "p2"), (P3, D0, "p3"), (D0, P4, "dash"),
          (P4, 13.1, "p4"), (13.1, END0, "p4h"), (END0, 20.3, "end")]
TOTAL = SCENES[-1][1]

# 字幕(開始, 終了, 文, 誰)。看護師の文は声なし。強める語は【】で囲む(オレンジ)
SUBS = [
    (T["hai"], T["hai"] + 0.9, "ハイッ!", "hk"),
    (T["oyobi"], OP_END, "オ呼ビデスカ?", "hk"),
    (OP_END + 0.15, P3, "え、呼んでないよ?", "ns"),
    (P3 + 0.1, D0, "空き【箱、ぶ】つけないようにね", "ns"),
    (T["kikoe"], END0, "ハコブ、ト聞コエマシタ。", "hk"),
]

# ---------------- 部品 ----------------
def text_img(txt, size, fill=INK, stroke=0, stroke_fill=(255, 255, 255), hl=ORANGE):
    """【】で囲んだ所をオレンジにした1行の文字を RGBA で返す"""
    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)
    w, h = bb[2] - bb[0] + 2 * stroke + 8, size + 2 * stroke + int(size * 0.35)
    im = Image.new("RGBA", (w, h), (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)
    return im

def paste_c(base, im, cx, cy, scale=1.0, rot=0.0, alpha=1.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)
    if alpha < 1:
        a = im.split()[3].point(lambda v: int(v * alpha)); im.putalpha(a)
    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)

# ---------------- 固定の部分 ----------------
logo = Image.open("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(["呼んでないのに、", "全力で来るロボット"]):
    BASE.alpha_composite(text_img(line, 86), (52, 228 + k * 96))
credit = text_img("4コママンガ『ハコブくん』第79話より(つくり話・絵は生成AIで作成)", 24, fill=GRAY)
BASE.alpha_composite(credit, (60, VY + VH + 8))

comic = Image.open("ep79.png").convert("RGB")
PANEL = {"p2": (22, 428, 527, 770), "p3": (553, 428, 1058, 770), "p4": (22, 905, 1058, 1322), "p4h": (300, 905, 790, 1322)}

def panel_img(name, scale):
    c = comic.crop(PANEL[name])
    c = c.resize((int(c.width * scale), int(c.height * scale)), Image.LANCZOS).convert("RGBA")
    d = ImageDraw.Draw(c); d.rectangle([0, 0, c.width - 1, c.height - 1], outline=INK, width=6)   # 枠を描き直す
    return c
_pc = {}
def panel(name, scale):
    k = (name, round(scale, 3))
    if k not in _pc: _pc[k] = panel_img(name, scale)
    return _pc[k]

# 冒頭の映像(848×1056)→ 区画(1080×1000)へ。y 240〜1025 を切り出す
OP_CROP = (0, 172, 848, 937)
def load_video(path):
    p = subprocess.run(["ffmpeg", "-v", "error", "-i", path, "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], capture_output=True)
    fr = np.frombuffer(p.stdout, np.uint8).reshape(-1, 1056, 848, 3)
    return fr
OPV = load_video("op.mp4")

def op_frame(t):
    i = min(int(round(t * FPS)), len(OPV) - 1)
    im = Image.fromarray(OPV[i]).crop(OP_CROP).resize((VW, VH), Image.LANCZOS)
    return im.convert("RGBA")

# 吹き出し(冒頭の看護師の声。画面の外から)
def bubble(lines, size=62):
    f = F(size)
    tw = max(f.getlength(l) 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)
    d.rounded_rectangle([60, 4, 60 + pw, 4 + ph], radius=46, fill=(255, 255, 255, 255), outline=INK, width=6)
    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")
    y = 4 + 25
    for l in lines:
        x = 60 + 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
BUB = bubble(["この箱、", "【運ぶ】の手伝って"])

def sfx_word(txt, size=150, fill=INK):
    return text_img(txt, size, fill=fill, stroke=12, stroke_fill=(255, 255, 255))
W_KYUI = sfx_word("ギュイーン", 120); W_KIKI = sfx_word("キキーッ", 150); W_EX = sfx_word("!", 210, ORANGE)
W_PIKON = sfx_word("ピコン", 110, ORANGE)
SLAM = text_img("呼んでない!", 165, fill=INK, stroke=14, stroke_fill=(255, 255, 255))

# 字幕
def subtitle(txt, who):
    fill = BLUE if who == "hk" else (255, 255, 255)
    return text_img(txt, 66, fill=fill, stroke=11, stroke_fill=INK, hl=(255, 140, 90))

# ---------------- 1コマ ----------------
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)

def frame(t):
    img = BASE.copy()
    vis = Image.new("RGBA", (VW, VH), PAPER + (255,))
    name, s0, s1 = next((n, a, b) for a, b, n in SCENES if a <= t < b) if t < TOTAL else ("end", END0, TOTAL)
    u = (t - s0) / (s1 - s0)
    cam = (0, 0)
    if name == "op":
        vis = op_frame(t)
        sc = pop(t, 0.0, 0.2)
        if sc and t < R0: paste_c(vis, BUB, 30 + BUB.width * sc / 2, 105, scale=sc)
        if A <= t < A + 0.65: paste_c(vis, W_EX, 800, 300, scale=pop(t, A, 0.12) or 0.01, rot=-8)
        if V0 <= t < R0 + 0.05: paste_c(vis, W_KYUI, 300, 860, scale=pop(t, V0, 0.12) or 0.01, rot=6)
        if R1 <= t < R1 + 0.7: paste_c(vis, W_KIKI, 300, 860, scale=pop(t, R1, 0.1) or 0.01, rot=-7)
    elif name == "dash":                           # 2回目の突進(冒頭の映像の続きを使う)
        vis = op_frame(R0 - 0.1 + (t - s0))
        k0 = s0 + (R1 - R0 + 0.1)
        if t > k0: paste_c(vis, W_KIKI, 300, 860, scale=pop(t, k0, 0.1) or 0.01, rot=-7)
    elif name in ("p2", "p3"):
        sc = 1.52 + 0.08 * ease(u)
        pi = panel(name, sc)
        paste_c(vis, pi, VW / 2, VH / 2)
        if name == "p3":                           # 「箱、ぶ」に反応する
            tp = s0 + 1.15
            if t >= tp:
                paste_c(vis, W_PIKON, 830, 330, scale=pop(t, tp, 0.12) or 0.01, rot=8)
                paste_c(vis, W_EX, 930, 470, scale=pop(t, tp, 0.12) or 0.01, rot=10)
    elif name == "p4":
        sc = 1.04 + 0.04 * ease(u)
        paste_c(vis, panel("p4", sc), VW / 2, VH / 2 + 120)
        k = slam(t, s0 + 0.1)
        if k: paste_c(vis, SLAM, VW / 2, 200, scale=k, rot=-4)
        cam = (shake(t, s0 + 0.24, 16), shake(t, s0 + 0.24, 10, 9, 29))
    elif name == "p4h":
        sc = 1.4 + 0.12 * ease(u)
        paste_c(vis, panel("p4h", sc), VW / 2, VH / 2)
    else:                                          # まとめの画面
        lines = [("名前が、ふだんの", 74, INK, 0.0), ("言葉と同じ音だと", 74, INK, 0.25),
                 ("呼ばれてなくても", 104, ORANGE, 0.9), ("呼ばれたと思う", 104, ORANGE, 1.15),
                 ("(音声で呼ぶ機械の「聞き違い」)", 40, GRAY, 2.0)]
        y = 190
        for txt, sz, col, d0 in lines:
            k = pop(t, s0 + d0, 0.16)
            if k: paste_c(vis, text_img(txt, sz, fill=col), VW / 2 - 40, y + sz / 2, scale=k)
            y += sz * 1.45 + (30 if sz == 74 and d0 > 0 else 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 in SUBS:
        if a <= t < b:
            si = subtitle(txt, who)
            k = pop(t, a, 0.12) or 0.01
            paste_c(img, si, 500, SUB_Y + 60, scale=min(k, 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}.png")
        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", "video.mp4"], stdin=subprocess.PIPE)
    for i in range(n):
        p.stdin.write(np.asarray(frame(i / FPS)).tobytes())
    p.stdin.close(); p.wait()
    print("映像:", "video.mp4", TOTAL, "秒")

_ショート専用/第79話_呼び名/mix79.py

"""第79話ショートの音を混ぜる → mix.wav、映像と合わせて out.mp4
声: voice/000.mp3(ハイッ)・001(オ呼ビデスカ)・002(ハコブ、ト聞コエマシタ)。なければ声なし
効果音: se/{名前}.mp3 か .wav。なければ仮の音(_制作ベースの se_pop・se_whoosh・se_ding)
"""
import os, subprocess, json
import numpy as np
SR = 48000
import short79 as S

def load(path):
    p = subprocess.run(["ffmpeg", "-v", "error", "-i", path, "-f", "f32le", "-ac", "1", "-ar", str(SR), "-"], capture_output=True)
    return np.frombuffer(p.stdout, np.float32).copy()

def find(name, fallback=None):
    for ext in (".mp3", ".wav"):
        if os.path.exists(f"se/{name}{ext}"): return f"se/{name}{ext}"
    return fallback

total = int(S.TOTAL * SR) + SR
out = np.zeros(total, np.float32)
def put(path, t, db=0.0):
    if not path or not os.path.exists(path): return 0
    a = load(path) * (10 ** (db / 20))
    i = int(t * SR); n = min(len(a), total - i)
    out[i:i + n] += a[:n]
    return len(a) / SR

POP, WHOOSH, DING = "base/se_pop.wav", "base/se_whoosh.wav", "base/se_ding.wav"
A, R0, R1, V0 = S.A, S.R0, S.R1, S.V0
SE = [  # (時刻, 名前, 仮の音, 大きさdB)
    (0.0, "se_pop_in", POP, -10),       # 吹き出し
    (A, "se_pikon", DING, -6),
    (V0, "se_rev", None, -10),
    (R0, "se_dash", WHOOSH, -6),
    (R1, "se_skid", None, -2),
    (S.OP_END + 0.05, "se_pop_in", POP, -12),
    (S.P3 + 1.15, "se_pikon", DING, -6),
    (S.D0, "se_dash", WHOOSH, -6),
    (S.D0 + R1 - R0 + 0.1, "se_skid", None, -2),
    (S.P4 + 0.1, "se_don", None, -4),
    (S.END0, "se_pop_in", POP, -12), (S.END0 + 0.9, "se_pop_in", POP, -12),
]
for t, name, fb, db in SE:
    put(find(name, fb), t, db)

# 看護師さんの声(「この箱、」と「運ぶの…」の間の間を詰める)
def put_arr(a, t, db=0.0):
    i = int(t * SR); n = min(len(a), total - i); out[i:i + n] += a[:n] * (10 ** (db / 20))
nh = load("nv/t_hakobu.mp3"); c0, c1 = int(0.60 * SR), int(0.90 * SR)
put_arr(np.concatenate([nh[:c0], nh[c1:]]), 0.0)
put("nv/t_yondenai_yo.mp3", S.OP_END + 0.1)
put("nv/o_akibako.mp3", S.P3 + 0.08)
for f, dt in (("nv/t_yondenai.mp3", 0.0), ("nv/o_yondenai.mp3", 0.025), ("nv/k_yondenai.mp3", 0.05)):
    put(f, S.P4 + 0.12 + dt, -3)     # 3人いっしょに「呼んでない!」

durs = {}
for k, key in enumerate(["hai", "oyobi", "kikoe"]):
    v = f"voice/{k:03d}.mp3"
    d = put(v, S.T[key], 0.0)
    if d: durs[key] = round(d, 2)
json.dump({"DUR": durs}, open("voice_durs.json", "w"))

peak = np.abs(out).max() or 1
if peak > 0.95: out *= 0.95 / peak
subprocess.run(["ffmpeg", "-v", "error", "-y", "-f", "f32le", "-ar", str(SR), "-ac", "1", "-i", "-", "mix.wav"], input=out.tobytes())
subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", "video.mp4", "-i", "mix.wav", "-c:v", "copy", "-c:a", "aac", "-b:a", "192k",
                "-af", "loudnorm=I=-16:TP=-1.5:LRA=11", "-shortest", "out.mp4"])
print("できあがり: out.mp4", durs)

_ショート専用/第80話_数字/README.md

# ショート専用「第80話 数字は目的を知らない」(2026-10-19・第79話の型)

縦長 1080×1920・22.8秒。冒頭は hakobu_motion.py の「件数で大喜び」、途中に「往復」(1錠ずつ10往復)。
作り方: hakobu_motion.py 件数で大喜び 往復 → op_joy.mp4・op_shuttle.mp4 → `python3 short80.py`(映像)→ `python3 mix80.py`(音・out.mp4)
材料: ep80.png・logo_dark.png・voice/000〜001(ハコブくん)・nv/(白石さん s_*・大城さん o_*。voice_eleven/白石・大城 の試し_NN)・se/(第79話の効果音)・base/(_制作ベースの se_pop・se_whoosh・se_ding)
声の間: 白石さんの2文は、途中の間を mix80.py の cut() で詰めている。往復のピコンは、届くたびに 6% ずつ高くしている

_ショート専用/第80話_数字/作る.sh

#!/bin/bash
# 第80話の声を作る。先に  read -s ELEVENLABS_API_KEY && export ELEVENLABS_API_KEY
# 白石さんの声は、先に _制作ベース で  python3 voice_design.py design 白石 → save 白石 番号  をすませておく
set -e
B=../../_制作ベース
id() { python3 -c "import json;print(json.load(open('$B/声の一覧.json'))['$1'])"; }
python3 $B/tts_eleven.py --voice gWJpc8whMBqnF7Y0kc9f --name ハコブ
python3 $B/tts_eleven.py --voice "$(id 白石)" --name 白石 --try "今月から、評価は配送件数です。|件数、先月の10倍です…。"
python3 $B/tts_eleven.py --voice "$(id 大城)" --name 大城 --try "わかりやすくて、いいわね。|まとめて持ってきなさいよ!|その数字、今すぐ捨てて。"
echo できあがり

_ショート専用/第80話_数字/narration.py

# ショート専用「第80話 数字は目的を知らない」— ハコブくんのセリフ(人のセリフは 作る.sh で --try)
SEGS = [
    ("op",  "1件目、配送シマス!", 0.2),
    ("p4",  "過去最高デス!", 0.3),
]
ELEVEN_READ = [
    ("1件目、配送シマス!", "1件目、配送します!"),
    ("過去最高デス!", "過去最高です!"),
]

_ショート専用/第80話_数字/short80.py

"""ショート専用「第80話 数字は目的を知らない」を縦長(1080×1920)で組む(第79話の型)。
使い方: python3 short80.py [--still 秒,秒,...] → video.mp4 、そのあと python3 mix80.py → out.mp4
入力: ep80.png・logo_dark.png・op_joy.mp4(件数で大喜び)・op_shuttle.mp4(往復)・voice/・nv/・se/・base/
"""
import os, sys, math, subprocess, json
import numpy as np
from PIL import Image, ImageDraw, ImageFont, ImageFilter

W, H, FPS = 1080, 1920, 30
PAPER = (245, 245, 240); INK = (13, 13, 13); GRAY = (136, 136, 136); ORANGE = (196, 71, 31); BLUE = (120, 200, 255)
BLACK = "/usr/share/fonts/opentype/noto/NotoSansCJK-Black.ttc"
F = lambda size: ImageFont.truetype(BLACK, size, index=0)

VX, VY, VW, VH = 0, 466, 1080, 974          # 映像・コマを置く区画
SUB_Y = 1488                                   # 字幕の上端(2行まで・〜1630)

# ---------------- 時刻 ----------------
JOY_END = 2.7                 # 冒頭(白石さん「今月から、評価は配送件数です」→ ハコブくん大喜び)
JOY_A = 1.55                  # 「件数」で反応(hakobu_motion.py の KensuJoy.A と同じ)
P1 = JOY_END                  # 1コマ目(大城さん「わかりやすくて、いいわね」)
SH0 = 4.7                     # 往復(hakobu_motion.py の Shuttle)
SH_T0, SH_TRIP, SH_N = 2.0, 0.28, 10
P3 = 10.0                     # 3コマ目(まとめて持ってきなさいよ!)
PS, PH, PO = 12.2, 14.8, 16.2 # 4コマ目を1人ずつ: 白石さん → ハコブくん → 大城さん
END0 = 18.6
SCENES = [(0.0, JOY_END, "joy"), (P1, SH0, "p1"), (SH0, P3, "shuttle"), (P3, PS, "p3"),
          (PS, PH, "s"), (PH, PO, "h"), (PO, END0, "o"), (END0, 22.8, "end")]
TOTAL = SCENES[-1][1]
T = dict(haisou=SH0 + 0.1, kako=PH + 0.1)
ARRIVE = [SH0 + SH_T0 + SH_TRIP * (k + 1) for k in range(SH_N)]   # 1件目〜10件目が届く時刻

SUBS = [
    (P1 + 0.1, SH0, "わかりやすくて、いいわね。", "ns"),
    (T["haisou"], SH0 + SH_T0, "1件目、配送シマス!", "hk"),
    (P3 + 0.1, PS, "まとめて持ってきなさいよ!", "ns"),
    (PS + 0.1, PH, "件数、先月の【10倍】です…。", "ns"),
    (T["kako"], PO, "過去最高デス!", "hk"),
    (PO + 0.1, END0, "その数字、今すぐ捨てて。", "ns"),
]

# ---------------- 部品 ----------------
def text_img(txt, size, fill=INK, stroke=0, stroke_fill=(255, 255, 255), hl=ORANGE):
    """【】で囲んだ所をオレンジにした1行の文字を RGBA で返す"""
    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)
    w, h = bb[2] - bb[0] + 2 * stroke + 8, size + 2 * stroke + int(size * 0.35)
    im = Image.new("RGBA", (w, h), (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)
    return im

def paste_c(base, im, cx, cy, scale=1.0, rot=0.0, alpha=1.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)
    if alpha < 1:
        a = im.split()[3].point(lambda v: int(v * alpha)); im.putalpha(a)
    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)

# ---------------- 固定の部分 ----------------
logo = Image.open("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(["件数で評価したら、", "1錠ずつ運ぶロボット"]):
    BASE.alpha_composite(text_img(line, 86), (52, 228 + k * 96))
credit = text_img("4コママンガ『ハコブくん』第80話より(つくり話・絵は生成AIで作成)", 24, fill=GRAY)
BASE.alpha_composite(credit, (60, VY + VH + 8))

comic = Image.open("ep80.png").convert("RGB")
PANEL = {"p1": (500, 28, 1058, 396), "p3": (553, 395, 1058, 860), "s": (22, 850, 450, 1322), "h": (370, 850, 700, 1322), "o": (640, 850, 1058, 1322)}

def panel_img(name, scale):
    c = comic.crop(PANEL[name])
    c = c.resize((int(c.width * scale), int(c.height * scale)), Image.LANCZOS).convert("RGBA")
    d = ImageDraw.Draw(c); d.rectangle([0, 0, c.width - 1, c.height - 1], outline=INK, width=6)   # 枠を描き直す
    return c
_pc = {}
def panel(name, scale):
    k = (name, round(scale, 3))
    if k not in _pc: _pc[k] = panel_img(name, scale)
    return _pc[k]

# 冒頭の映像(848×1056)→ 区画(1080×1000)へ。y 240〜1025 を切り出す
OP_CROP = (0, 172, 848, 937)
def load_video(path):
    p = subprocess.run(["ffmpeg", "-v", "error", "-i", path, "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], capture_output=True)
    fr = np.frombuffer(p.stdout, np.uint8).reshape(-1, 1056, 848, 3)
    return fr
OPJ = load_video("op_joy.mp4"); OPS = load_video("op_shuttle.mp4")

def op_frame(t, v):
    i = min(max(int(round(t * FPS)), 0), len(v) - 1)
    im = Image.fromarray(v[i]).crop(OP_CROP).resize((VW, VH), Image.LANCZOS)
    return im.convert("RGBA")

# 吹き出し(冒頭の看護師の声。画面の外から)
def bubble(lines, size=62):
    f = F(size)
    tw = max(f.getlength(l) 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)
    d.rounded_rectangle([60, 4, 60 + pw, 4 + ph], radius=46, fill=(255, 255, 255, 255), outline=INK, width=6)
    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")
    y = 4 + 25
    for l in lines:
        x = 60 + 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
BUB = bubble(["今月から、評価は", "【配送件数】です"])

def sfx_word(txt, size=150, fill=INK):
    return text_img(txt, size, fill=fill, stroke=12, stroke_fill=(255, 255, 255))
W_EX = sfx_word("!", 210, ORANGE); W_PIKON = sfx_word("ピコン", 110, ORANGE)
W_KEN = [sfx_word(f"{k + 1}件目", 170, ORANGE) for k in range(SH_N)]
W_10 = sfx_word("10倍", 230, ORANGE); W_KAKO = sfx_word("過去最高!", 120, ORANGE); W_ZUN = sfx_word("ズーン", 130)

# 字幕
def subtitle(txt, who):
    fill = BLUE if who == "hk" else (255, 255, 255)
    return text_img(txt, 66, fill=fill, stroke=11, stroke_fill=INK, hl=(255, 140, 90))

# ---------------- 1コマ ----------------
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)

def frame(t):
    img = BASE.copy()
    vis = Image.new("RGBA", (VW, VH), PAPER + (255,))
    name, s0, s1 = next((n, a, b) for a, b, n in SCENES if a <= t < b) if t < TOTAL else ("end", END0, TOTAL)
    u = (t - s0) / (s1 - s0)
    cam = (0, 0)
    if name == "joy":
        vis = op_frame(t, OPJ)
        sc = pop(t, 0.0, 0.2)
        if sc: paste_c(vis, BUB, 30 + BUB.width * sc / 2, 105, scale=sc)
        if JOY_A <= t: paste_c(vis, W_PIKON, 830, 330, scale=pop(t, JOY_A, 0.12) or 0.01, rot=8)
    elif name == "shuttle":
        vis = op_frame(t - SH0, OPS)
        n = sum(1 for a_ in ARRIVE if t >= a_)
        if n:
            paste_c(vis, W_KEN[n - 1], VW / 2 - 40, 150, scale=pop(t, ARRIVE[n - 1], 0.1) or 0.01, rot=-4)
    elif name in ("p1", "p3"):
        sc = 1.52 + 0.08 * ease(u)
        paste_c(vis, panel(name, sc), VW / 2, VH / 2)
    elif name in ("s", "h", "o"):
        sc = 1.5 + 0.08 * ease(u)
        paste_c(vis, panel(name, sc), VW / 2, VH / 2)
        if name == "s":
            k = slam(t, s0 + 1.0)
            if k: paste_c(vis, W_10, 820, 220, scale=k, rot=-6)
            cam = (shake(t, s0 + 1.14, 10), 0)
        if name == "o" and t >= s0 + 1.6:
            paste_c(vis, W_ZUN, 230, 860, scale=pop(t, s0 + 1.6, 0.15) or 0.01, rot=-5)
    else:                                          # まとめの画面
        lines = [("数字を目標にした", 74, INK, 0.0), ("その日から、", 74, INK, 0.25),
                 ("機械は数字に", 104, ORANGE, 0.9), ("仕え始める", 104, ORANGE, 1.15),
                 ("(グッドハートの法則)", 40, GRAY, 2.0)]
        y = 190
        for txt, sz, col, d0 in lines:
            k = pop(t, s0 + d0, 0.16)
            if k: paste_c(vis, text_img(txt, sz, fill=col), VW / 2 - 40, y + sz / 2, scale=k)
            y += sz * 1.45 + (30 if sz == 74 and d0 > 0 else 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 in SUBS:
        if a <= t < b:
            si = subtitle(txt, who)
            k = pop(t, a, 0.12) or 0.01
            paste_c(img, si, 500, SUB_Y + 60, scale=min(k, 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}.png")
        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", "video.mp4"], stdin=subprocess.PIPE)
    for i in range(n):
        p.stdin.write(np.asarray(frame(i / FPS)).tobytes())
    p.stdin.close(); p.wait()
    print("映像:", "video.mp4", TOTAL, "秒")

_ショート専用/第80話_数字/mix80.py

"""第79話ショートの音を混ぜる → mix.wav、映像と合わせて out.mp4
声: voice/000.mp3(ハイッ)・001(オ呼ビデスカ)・002(ハコブ、ト聞コエマシタ)。なければ声なし
効果音: se/{名前}.mp3 か .wav。なければ仮の音(_制作ベースの se_pop・se_whoosh・se_ding)
"""
import os, subprocess, json
import numpy as np
SR = 48000
import short80 as S

def load(path):
    p = subprocess.run(["ffmpeg", "-v", "error", "-i", path, "-f", "f32le", "-ac", "1", "-ar", str(SR), "-"], capture_output=True)
    return np.frombuffer(p.stdout, np.float32).copy()

def find(name, fallback=None):
    for ext in (".mp3", ".wav"):
        if os.path.exists(f"se/{name}{ext}"): return f"se/{name}{ext}"
    return fallback

total = int(S.TOTAL * SR) + SR
out = np.zeros(total, np.float32)
def put(path, t, db=0.0):
    if not path or not os.path.exists(path): return 0
    a = load(path) * (10 ** (db / 20))
    i = int(t * SR); n = min(len(a), total - i)
    out[i:i + n] += a[:n]
    return len(a) / SR

POP, WHOOSH, DING = "base/se_pop.wav", "base/se_whoosh.wav", "base/se_ding.wav"
def put_arr(a, t, db=0.0):
    i = int(t * SR); n = min(len(a), total - i); out[i:i + n] += a[:n] * (10 ** (db / 20))
def cut(a, t0, t1):                       # 声の途中の間を詰める
    return np.concatenate([a[:int(t0 * SR)], a[int(t1 * SR):]])
def pitch(a, k):                          # 速さを変えて音の高さを変える(k>1 で高く短く)
    idx = np.arange(0, len(a) - 1, k); return np.interp(idx, np.arange(len(a)), a).astype(np.float32)

put(POP, 0.0, -10)                                        # 吹き出し
put(find("se_pikon", DING), S.JOY_A, -6)
put(POP, S.P1 + 0.02, -12)
pik = load(find("se_pikon", DING)); dash = load(find("se_dash", WHOOSH))
for k, ta in enumerate(S.ARRIVE):                         # 往復のたびにシュッ、届くたびにピコン(だんだん高く)
    put_arr(dash[:int(0.25 * SR)] * np.hanning(int(0.25 * SR)) , ta - S.SH_TRIP, -12)
    put_arr(pitch(pik, 1 + 0.06 * k), ta - 0.02, -9)
put(POP, S.P3 + 0.02, -12)
put(find("se_don", None), S.PS + 1.0, -4)                # 10倍
put_arr(pitch(load(find("se_don", None)), 0.7), S.PO + 1.6, -6)   # ズーン(低く)
put(POP, S.END0, -12); put(POP, S.END0 + 0.9, -12)

# 声
put_arr(cut(load("nv/s_kensu.mp3"), 0.34, 0.92), 0.0)
put("nv/o_wakari.mp3", S.P1 + 0.1)
put("voice/000.mp3", S.T["haisou"])
put("nv/o_matomete.mp3", S.P3 + 0.1)
put_arr(cut(load("nv/s_10bai.mp3"), 0.42, 1.12), S.PS + 0.1)
put("voice/001.mp3", S.T["kako"])
put("nv/o_sutete.mp3", S.PO + 0.1)

peak = np.abs(out).max() or 1
if peak > 0.95: out *= 0.95 / peak
subprocess.run(["ffmpeg", "-v", "error", "-y", "-f", "f32le", "-ar", str(SR), "-ac", "1", "-i", "-", "mix.wav"], input=out.tobytes())
subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", "video.mp4", "-i", "mix.wav", "-c:v", "copy", "-c:a", "aac", "-b:a", "192k",
                "-af", "loudnorm=I=-16:TP=-1.5:LRA=11", "-shortest", "out.mp4"])
print("できあがり: out.mp4")

_ショート専用/第81話_かぼちゃ/narration.py

# ショート専用。声は --try で作る(_ショート専用/8話の声.sh)
SEGS = []

バックの警告音(_制作ベース/sound/se_backbeep.wav)を作ったコード

import numpy as np, subprocess
SR=48000; n=int(0.25*SR); t=np.arange(n)/SR
x=0.8*np.sin(2*np.pi*1050*t)+0.15*np.sin(2*np.pi*2100*t)
f=int(0.006*SR); env=np.ones(n); env[:f]=np.linspace(0,1,f); env[-f:]=np.linspace(1,0,f)
x=(x*env*0.5).astype(np.float32)
subprocess.run(['ffmpeg','-v','error','-y','-f','f32le','-ar',str(SR),'-ac','1','-i','-','base/se_backbeep.wav'],input=x.tobytes())

_FV_ハコブくん/作る.sh

#!/bin/bash
# サイトのトップ(FV)のハコブくんの動画の声を、YouTube と同じハコブくんの声(ElevenLabs)で作り直す。
# 先に  read -s ELEVENLABS_API_KEY && export ELEVENLABS_API_KEY
# 元の動画のセリフ(文字起こし): 失礼します。/ハコブくんです。/お薬をお届けに来ました。/お名前をご確認ください。
set -e
cd "$(dirname "$0")"
python3 ../_制作ベース/tts_eleven.py --voice "gWJpc8whMBqnF7Y0kc9f" --name ハコブ --try "失礼します。|ハコブくんです。|お薬をお届けに来ました。|お名前をご確認ください。"
echo できあがり: voice_eleven/ハコブ/試し_01〜04.mp3

_FV_ハコブくん/02を作り直す.sh

#!/bin/bash
# 「ハコブくんです。」の読み方の候補を5つ作る(02 のイントネーションが変だったため)
# 先に  read -s ELEVENLABS_API_KEY && export ELEVENLABS_API_KEY
set -e
cd "$(dirname "$0")"
python3 ../_制作ベース/tts_eleven.py --voice "gWJpc8whMBqnF7Y0kc9f" --name ハコブ_02候補 --try "ハコブ君です。|ハコブくん、です。|はこぶくんです。|わたし、ハコブくんです。|ハコブです。"
echo できあがり: voice_eleven/ハコブ_02候補/試し_01〜05.mp3

トップの動画の声を書き起こしたコード

# トップの動画(FV)の声を書き起こし、セリフごとの時刻を出す(faster-whisper・small モデル・CPU)
import numpy as np, subprocess
from faster_whisper import WhisperModel
a=np.frombuffer(subprocess.run(['ffmpeg','-v','error','-i','fv.mp4','-f','f32le','-ac','1','-ar','16000','-'],capture_output=True).stdout,np.float32).copy()
m=WhisperModel('small',device='cpu',compute_type='int8')
segs,info=m.transcribe(a,language='ja',word_timestamps=True)
for s in segs:
    print(round(s.start,2),round(s.end,2),s.text)
    for w in s.words: print('   ',round(w.start,2),round(w.end,2),w.word)

トップの動画の声を差し替えたコード

# 新しい声4文を、元の動画で口が動いている時刻に置き、映像はそのままで音だけ差し替える
# l1〜l4.mp3 = voice_eleven/ハコブ/試し_01・ハコブ_02候補/試し_04・ハコブ/試し_03・ハコブ/試し_04
import subprocess, numpy as np
SR = 48000
def raw(p): return np.frombuffer(subprocess.run(['ffmpeg','-v','error','-i',p,'-f','f32le','-ac','1','-ar',str(SR),'-'],capture_output=True).stdout,np.float32)
def trim(a):
    e=np.abs(a); idx=np.where(e>e.max()*0.06)[0]
    return a[max(0,idx[0]-int(0.02*SR)):idx[-1]+int(0.1*SR)]
l2 = raw('l2.mp3')[int(0.70*SR):]          # 「わたし、ハコブくんです。」から「わたし、」を切り落とす
out = np.zeros(int(10.0*SR), np.float32)
for a, t in [(trim(raw('l1.mp3')),0.5), (trim(l2),1.8), (trim(raw('l3.mp3')),3.4), (trim(raw('l4.mp3')),5.9)]:
    i = int(t*SR); out[i:i+len(a)] += a; print(t, round(t+len(a)/SR,2))
subprocess.run(['ffmpeg','-v','error','-y','-f','f32le','-ar',str(SR),'-ac','1','-i','-','voice_mix2.wav'], input=out.tobytes())
subprocess.run(['ffmpeg','-v','error','-y','-i','fv_orig.mp4','-i','voice_mix2.wav','-map','0:v:0','-map','1:a:0','-c:v','copy',
                '-af','loudnorm=I=-16:TP=-1.5:LRA=11','-ar','48000','-ac','2','-c:a','aac','-b:a','160k','-t','10.04',
                '-movflags','+faststart','fv_new2.mp4'])

LOG354 の表紙を作ったコード

# LOG354 の表紙(1920×1080)。作り直したショート3本のコマを並べ、最後に白黒(墨色〜紙色)にする
from PIL import Image, ImageDraw, ImageFont, ImageFilter, ImageEnhance
import subprocess, glob
def frame(n,t):
    p=glob.glob(f'/home/claude/batch/ep/{n}/out_*.mp4')[0]
    subprocess.run(['ffmpeg','-v','error','-y','-ss',str(t),'-i',p,'-frames:v','1','/tmp/c.png']); return Image.open('/tmp/c.png').convert('RGB').copy()
W,H=1920,1080; INK=(13,13,13); PAPER=(245,245,240)
im=Image.new('RGB',(W,H),INK)
mono=lambda s,b=True: ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf' if b else '/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf', s)
frames=[('81',2.3),('87',3.9),('20',4.0)]
ph_h=820; ph_w=int(ph_h*9/16)
for k,(n,t) in enumerate(frames):
    f=frame(n,t).resize((ph_w,ph_h),Image.LANCZOS)
    card=Image.new('RGBA',(ph_w+20,ph_h+20),PAPER+(255,)); card.paste(f,(10,10))
    m=Image.new('L',card.size,0); ImageDraw.Draw(m).rounded_rectangle([0,0,card.width-1,card.height-1],radius=30,fill=255); card.putalpha(m)
    c2=card.rotate([-5,0,5][k],expand=True,resample=Image.BICUBIC)
    sh=c2.split()[3].point(lambda v:int(v*0.55)).filter(ImageFilter.GaussianBlur(18))
    x=790+k*345; y=[150,100,150][k]
    im.paste(Image.new('RGB',c2.size,(0,0,0)),(x+14,y+20),sh); im.paste(c2,(x,y),c2)
d=ImageDraw.Draw(im)
d.text((110,120),'YOUTUBE SHORTS · 28 DAYS',font=mono(26,False),fill=PAPER)
d.text((100,170),'81%',font=mono(300),fill=PAPER)
d.rectangle([110,520,640,600],fill=PAPER); d.text((126,526),'SWIPED AWAY.',font=mono(66),fill=INK)
d.text((110,660),'SO THE SHORTS',font=mono(58),fill=PAPER)
d.text((110,734),'BECAME A MANGA.',font=mono(58),fill=PAPER)
bar=Image.new('RGB',(590,36),INK); bd=ImageDraw.Draw(bar)
for x in range(int(590*0.19)-40,600,16): bd.line([(x,36),(x+36,0)],fill=PAPER,width=2)
bd.rectangle([0,0,int(590*0.19),36],fill=PAPER); bd.rectangle([0,0,589,35],outline=PAPER,width=3)
im.paste(bar,(110,880))
d.text((110,930),'19% KEPT WATCHING',font=mono(22,False),fill=PAPER); d.text((540,930),'81% SWIPED',font=mono(22,False),fill=PAPER)
im.save('LOG354_cover.png'); im.convert('RGB').save('LOG354_cover.jpg',quality=88)   # 初版(色付き・NG)
g=ImageEnhance.Contrast(im.convert('L')).enhance(1.15)                                  # 白黒にする
lut=[(int(13+(245-13)*v/255),int(13+(245-13)*v/255),int(13+(240-13)*v/255)) for v in range(256)]
Image.merge('RGB',[g.point([l[0] for l in lut]),g.point([l[1] for l in lut]),g.point([l[2] for l in lut])]).save('LOG354_cover_v2.jpg',quality=88)