マンガのショートを20本作る ― 吹き出しを切らずに切り抜く
マンガの吹き出しは、コマの枠の中に収まっているとは限りません。セリフが長いときや、勢いを出したいとき、吹き出しは枠を突き抜けて隣の余白まではみ出して描かれます。紙のマンガでは気にならないこの描き方が、マンガを縦長の動画に作り直すときには困りものになります。コマを四角く切り取ると、はみ出した部分が一緒に切れてしまうからです。
この記事は、4コママンガ『ハコブくん』を YouTube のショート動画(縦長で60秒までの短い動画)に作り直す作業の記録です。ハコブくんは、病院で薬や検体を運ぶロボットが、人の言葉をまっすぐ受け取りすぎて失敗するつくり話です。ショートは、最初の数秒でハコブくんが動き、そのあとマンガのコマを1枚ずつ見せ、看護師さんやハコブくんのセリフを声で読み上げる、約20秒の動画にしています(作り方の型は LOG354 で決めました)。
ひと晩で20本を作り、見せたところで最初に返ってきたのが、この一言でした。
吹き出しってもっと綺麗に切り取れないの?
続けて「見切れているけど?」。調べると、吹き出しが切れていた原因は、コマを決まった四角の範囲で切り取っていたことでした。 枠からはみ出した吹き出しは、四角の外に出た分だけ消えていました。そこで、絵の中から吹き出しを自動で見つけ、切り取る範囲を吹き出しに合わせて動かし、それでも枠の外にはみ出すものは形のまま上に重ねるようにしました。すると今度は「切り抜きが汚い、ガサガサしている」。重ねた吹き出しのふちに、まわりの絵の切れ端が付いていたためです。吹き出しの中だけを取り出し、ふちの線を描き直して直しました。

| やったこと | なぜ | 結果 |
|---|---|---|
まだショートにしていないマンガから20話を選んだ | ショートだけのために作る動画を増やすため | 第7〜98話から、意外な一言で終わる20話。長さは16〜30秒、20本で約7分50秒 |
| 声と背景を用意した | 話ごとに場所と登場人物が違うため | 107文の声を作った。声を作るサービスの上限(自作の声10個)に当たり、2人は別の人の声を借りた。背景は8枚を新しく作り、12話は前の絵を使い回した |
冒頭のハコブくんの動きを20個足した | 20本の頭が同じ動きに見えないようにするため | 忍者のように気配を消す、イケメンの声で話す、外装を外すと光る目が1つ、など話ごとに違う動き |
吹き出しを切らずにコマを切り取る仕組みを作った | 枠からはみ出した吹き出しが切れていたため | 59コマのうち34コマで、枠の外にはみ出した吹き出しを形のまま重ねた |
重ねた吹き出しのふちを描き直した | ふちがガサガサで、枠の線が2重になっていたため | ふちが一本のなめらかな線になり、枠も1本にそろった |
X への投稿を、YouTube の公開予約に合わせて自動で予約するようにした | 動画を上げるたびに投稿を手で予約するのは続かないため | YouTube で予約した日時に、X の投稿が自動で入る。31本ぶんの投稿文を用意した |
どのショートも、まだ YouTube には出ていません(10月26日から平日に1本ずつ公開する予約にしています)。作り直す前のショートは、見た人の81.0%がスワイプして次の動画へ流していました(LOG354)。吹き出しがきちんと読めることが、その割合を下げる助けになるかは、公開後に同じ分析画面で確かめます。
ここから下は作業の記録です。指摘を受けて直した所と、使ったコード・設定の全文を残します。
作業ログ ― ショート専用のフォルダの20本のうち、10本が YouTube に出ていなかった
最初の質問は「ショート専用で YouTube に投稿されていない動画はどれか」でした。ショート専用の動画(本編がなく、ショートだけの企画)は、KIOXIA(外付けの SSD)の r2r2r-data/videos/_ショート専用/ に1話1フォルダで置いています。Mac が1時間ごとに書き出している自分のチャンネルの一覧(_制作ベース/youtube_videos.json)と、フォルダの中の mp4 を突き合わせました。
cd $HOME/mnt/KIOXIA/r2r2r-data/videos/_ショート専用 && for d in */; do printf "%s: " "$d"; ls "$d" | grep -E '^ショート_.*\.mp4$' | tr '\n' ' '; echo; done
結果は次のとおりです(照らし合わせたのは 18:05 時点の一覧)。
| 状態 | 話 |
|---|---|
| まだ出していない(10本) | 第22話_消毒、第24話_シュレッダー、第29話_ハワイ、第43話_謝罪、第81話_かぼちゃ、第82話_ため息、第83話_確認、第85話_取り消し、第87話_小声、第88話_ついてきて |
| 予約済み(1本) | 第41話_公平(10/25 8:30) |
| 公開済み(9本) | 第18・19・20・21・48・67・79・80・86話 |
10本は、その場で YouTube に上げてもらいました。そのあとの依頼がこれです。
新しいショート専用の動画を20本作成してください。動画作成のルールをよく読んでください。背景画像はchatGPTに依頼するので指示を出してください(前に作ったの使い回しでも可。新しいのが必要なら作ります)
この記事の「見込める効果」は、ことわりのない限り、まだ測っていない見込みです。数字で書いてあるもの(長さ・コマの数・予約の件数)は、書き出した動画と設定のファイルから数えた値です。
20話を選び、声を4人ぶん頼み、背景を8枚頼んだ
動画の作り方の決まり(_制作ベース/video_rules.md)の0節と9節、前回の10本の台本、サイトのマンガの一覧(pixelstomotors.com/manga)を読んでから、まだショートにしていない回の中から「え?」となる一言で終わる20話を選びました。
| 話 | 題材 |
|---|---|
| 第7話 ピンポン | 深刻な告知の最中に、検便容器の回収をアナウンスする |
| 第9話 外装 | 外装を外した中身に、光る目が1つギョロッ |
| 第17話 イケボ | アップデートで「御意。姫君」と話し出す |
| 第23話 忍者 | 静音モードで、気配ゼロで背後にいる |
| 第46話 メニュー | 音量を下げたいだけなのに、設定がどこまでも深い |
| 第47話 8時間 | 8時間前から警告していたのに、誰も気づかない |
| 第51話 自己診断 | 目が悪くなったのに、自己診断は「正常」 |
| 第52話 夜勤 | 気をきかせて「今月の夜勤、40%多いです」 |
| 第60話 再現しない | 見張っている間は、バグが起きない |
| 第62話 確信度 | 「了解しました」と即答。本当は自信62% |
| 第63話 通行止め | 昨日できた壁の前で「経路、再計算」をくり返す |
| 第65話 道案内 | 道を聞いただけで「職員認証が必要です」 |
| 第71話 置いてけぼり | 案内したら、振り返ると誰もいない |
| 第72話 トイレ | 「トイレどこ?」にも「経路を継続します」 |
| 第84話 プリン | 先生の声まねで頼まれたプリンを、本物の先生に届ける |
| 第89話 お手すき | 「お手すきの時で…」を言葉どおり後回しにする |
| 第90話 伝言 | 遠慮がちな伝言を要約して「誤りがあります」と断定 |
| 第91話 心拍数 | 笑顔で隠した緊張を「心拍数1.8倍」と発表する |
| 第92話 なかったことに | 「なかったことに」を「記録は消せません」と断る |
| 第98話 起こさない | 「絶対に起こさないで」を一晩守りきる |
新しい声を4つ作る手順
20話には、これまでのショートに出ていない人が4人います。入院患者の中村拓也さん、面会に来た森田茂さんと森田久美子さん、そして第17話のアップデートで声が変わったハコブくん(イケメンの声)です。声は ElevenLabs(声を作る AI のサービス)の「声をデザインする」機能で作ります。API の鍵を使うので、Mac のターミナルで動かしてもらいました。voice_design.py に4人の説明を足してあります(ファイルの全文は最後にあります)。
cd ~/Library/Mobile\ Documents/com~apple~CloudDocs/デスクトップ/ADAPT\ DESIGN/じぶんの/PixelstoMotors/デジタルツイン学習R2R2R/動画/_制作ベース
read -s ELEVENLABS_API_KEY && export ELEVENLABS_API_KEY
python3 voice_design.py design 中村
python3 voice_design.py design 森田茂
python3 voice_design.py design 森田久美子
python3 voice_design.py design ハコブ_イケボ
声の候補/{名前}/候補1〜3.mp3 を聞いて選んでもらった番号が、これです。
ハコブ_イケボ1 森田久美子2 森田茂1 中村1
20話の107文は _ショート専用/20話のセリフ.json に書き、20話の声.sh でまとめて声にします。
python3 voice_design.py save ハコブ_イケボ 1
python3 voice_design.py save 森田久美子 2
python3 voice_design.py save 森田茂 1
python3 voice_design.py save 中村 1
cd ../_ショート専用 && bash 20話の声.sh
背景は8枚を新しく、12話は使い回した
背景は ChatGPT に頼みます。ロボットのいない病室の絵(bg_empty.png)を添付し、カメラの高さと床の位置をそろえたまま、場所だけを変えてもらう決まりです(_ショート専用/背景/背景のプロンプト.md)。新しく頼んだのは、前に作った絵では場所が合わない8話です。
| 新しく作った背景 | 使い回した背景 |
|---|---|
第7話 面談室、第9話 機材室、第52話 休憩室、第63話 工事中のパーテーション、第72話 1階の受付そばの廊下、第90話 医局、第91話 採血室、第98話 夜明け前の当直室の前 | 第17話←第24話、第23話←第67話、第46話←第82話、第47話←第48話、第51話←第41話、第60話←第83話、第62話←第19話、第65話←第22話、第71話←第86話、第84話←bg_empty、第89話←第85話、第92話←第20話 |
マンガの登場人物の名前が出てこない回は、見た目から読む人を決めました。第17話の眼鏡の人は寺島さん、お団子の髪の人は大城さん。名前の出ないお団子の看護師さん(第62・65・72・89・91話)は大城さんの声、第63話のボブの人は高杉さんの声で読みます。第84話の声まね「プリンを持ってきてくれ」は、田中先生本人の声で読ませました。ハコブくんが声だけでは聞き分けられなかった、という話が伝わるようにするためです。
ElevenLabs の自作の声が上限の10個で、2人は別の人の声を借りた
声を保存するところで止まりました。ターミナルに出た表示です。
(base) shimazueiichi@shimadzueiichinoMacBook-Pro _ショート専用 % cd ../_制作ベース
python3 voice_design.py save 森田茂 1
python3 voice_design.py save 中村 1
止まりました: 400 {"detail":{"type":"invalid_request","code":"bad_request","message":"You have reached your maximum amount of custom voices (10 / 10). You can upgrade your subscription to increase your custom voice limit.","status":"voice_limit_reached","request_id":"7234d8d2f45043ad22b6322c4ecaf275"}}
止まりました: 400 {"detail":{"type":"invalid_request","code":"bad_request","message":"You have reached your maximum amount of custom voices (10 / 10). You can upgrade your subscription to increase your custom voice limit.","status":"voice_limit_reached","request_id":"6f872af8955faf9b56b1325d45ee6d9b"}}
(base) shimazueiichi@shimadzueiichinoMacBook-Pro _制作ベース % cd ../_制作ベース
python3 voice_design.py save 森田茂 1
python3 voice_design.py save 中村 1
cd ../_ショート専用 && bash 20話の声.sh
止まりました: 400 {"detail":{"type":"invalid_request","code":"bad_request","message":"You have reached your maximum amount of custom voices (10 / 10). You can upgrade your subscription to increase your custom voice limit.","status":"voice_limit_reached","request_id":"093f9143dadfc922c3d5576010854633"}}
止まりました: 400 {"detail":{"type":"invalid_request","code":"bad_request","message":"You have reached your maximum amount of custom voices (10 / 10). You can upgrade your subscription to increase your custom voice limit.","status":"voice_limit_reached","request_id":"e2f5393a23557d0c34a8d4b221ea2415"}}
== 第7話_ピンポン
== 第9話_外装
== 第17話_イケボ
== 第23話_忍者
== 第46話_メニュー
== 第47話_8時間
== 第51話_自己診断
== 第52話_夜勤
== 第60話_再現しない
== 第62話_確信度
== 第63話_通行止め
== 第65話_道案内
! 森田茂 の声がまだ保存されていないので、とばしました
== 第71話_置いてけぼり
== 第72話_トイレ
! 中村 の声がまだ保存されていないので、とばしました
== 第84話_プリン
! 中村 の声がまだ保存されていないので、とばしました
== 第89話_お手すき
== 第90話_伝言
== 第91話_心拍数
! 中村 の声がまだ保存されていないので、とばしました
== 第92話_なかったことに
== 第98話_起こさない
とばした声があります。声を保存してから、もう一度 bash 20話の声.sh
(base) shimazueiichi@shimadzueiichinoMacBook-Pro _ショート専用 %
いまのプランでは、自分で作った声は10個までです。ハコブ_イケボと森田久美子さんで10個に達し、森田茂さんと中村さんが入りませんでした。選べる道は2つです。
| 道 | 中身 |
|---|---|
| 今ある声を借りる | 中村さん=槙原さん(エンジニア)の声、森田茂さん=田中先生(医師)の声。借りた声の本人が同じ話に出てこない組み合わせにする。追加の費用なし |
| 枠を空ける | ElevenLabs の画面で使っていない声を2つ消して、保存をやり直す |
追加する?
声の枠を増やすにはプランを変える必要があるため、借りる方にしました。20話の声.sh の、人の名前から声を選ぶ所に2行を足しています(中村→槙原、森田茂→田中)。足りなかった5文だけを作り直してもらい、20話ぶんの声がそろいました。
声のファイルは Mac の iCloud にできます。作業はクラウドの作業環境(メモリが多い)で行うため、tgz にまとめて KIOXIA 経由で取り込み、md5(ファイルの中身から作る短い値)が同じことを確かめてから使いました。
台本ファイル(spec)を、声の長さから時刻が決まる形にした
1本のショートは、話ごとの台本ファイル spec_epNN.py から組みます。見出し、冒頭の動き、コマの切り抜き、セリフと字幕、まとめの画面を書き、マンガショート_mkshort.py に渡すと映像と音が1本になります。前回までと変えたのは3つです。
1つ目は、冒頭のハコブくんの動きの時刻を、声の長さから台本の中で決めるようにしたことです。たとえば第98話では、小芝先生の「朝まで、絶対に起こさないでね」が終わってから0.2秒後にハコブくんが返事をし、その返事が終わってから見張りの動きに入ります。
l1 = ns("小芝", 1, "朝まで、|【絶対に】起こさないでね。", at=0.9)
t2 = 0.9 + d(l1, 2.0) + 0.2
l2 = hk(1, "了解しました。", at=t2)
S = t2 + d(l2, 1.0) + 0.2
M = S + 2.4
DUR = M + 1.1
MOTION = mot("見張り", EP, S=S, M=M, dur=DUR)
d() は声のファイルの、前後の無音を落とした長さです(common24.py の vlen)。声がまだないときは仮の長さ(引数の2つ目)で組めるので、声を待たずに冒頭の動きと台本を進められました。
2つ目は、字幕を || でページに分けられるようにしたことです。字幕は1ページ2行まで、1行14字までです。3行にすると、画面の下のふち(y1640)より下にはみ出していました。長いセリフは、声の長さを字数で割り振って、ページを順に出します。字数は checksubs.py で確かめます。
$ python3 checksubs.py
要確認: 0
3つ目は、横に長い4コマ目の扱いです。4コマ目が横に長い回は、幅1040pxに縮めて入れると、小さな帯になってしまいます。話している人ごとに、500〜650pxの幅で2つに切り分け、2つの場面にしました。
- ねらい:4コマ目のセリフと表情を、スマホの縦長の画面でも読める大きさで見せる
- 変わったこと:20話のうち13話で、横に長いコマを話す人ごとに2つの場面に分けた。コマは20話で59コマになった
- 見込める効果:オチのコマで字が小さくて読めない、ということが減る。最後まで見てもらえたかは、公開後の「視聴を継続」の割合で確かめる
作業の途中では、各場面の頭の静止画を書き出して1枚に並べ(sheets.py)、目で見て切り抜きと字幕を確かめました。
冒頭のハコブくんの動きを20個足した
冒頭の数秒は、コードで動かすハコブくんです(hakobu_motion.py。体の画像を揺らす・跳ねさせる・傾ける、画面の顔とリングの光を描き替える)。前回、似た動きが並ぶと「みんな同じ動き」に見えると指摘されたので、20話それぞれに別の動きを足しました。
| 動きの名前 | 話 | 中身 |
|---|---|---|
| アナウンス | 第7話 | 胸を張って大声のアナウンス |
| ギョロ | 第9話 | 外装を外した黒い体に、光る目が1つ |
| イケボ | 第17話 | 流し目のイケメン顔で話す |
| 忍者 | 第23話 | スーッと音もなく近づき、背後で止まる |
| メニュー | 第46話 | 画面に設定の階層がどんどん深く出る |
| カタッ | 第47話 | 右の車輪がカタッと沈む |
| 自己診断 | 第51話 | 画面に「全機能、正常」と出る |
| 夜勤スロット | 第52話 | 画面に「今月ノ夜勤 +33%」 |
| ピタッ | 第60話 | 見られた瞬間にピタッと止まる |
| 確信度 | 第62話 | 即答の笑顔の下に確信度のゲージ |
| 経路再計算 | 第63話 | 壁の前でウロウロ |
| 職員認証 | 第65話 | 画面に認証の要求 |
| 置いてけぼり | 第71話 | 速いまま案内して進む |
| 案内 | 第72話 | 画面に「目的地 405号室」 |
| プリン | 第84話 | プリンを運んで来る |
| 優先度低 | 第89話 | 依頼を受けて「低」の箱に入れる |
| 伝言 | 第90話 | 画面に「伝言 要約中」 |
| 心拍 | 第91話 | 画面に心拍の波形 |
| 消せない | 第92話 | ヒソヒソ声の依頼に首を振る |
| 見張り | 第98話 | 夜の当直室の前で朝まで待機 |
最初の1秒が止まった絵にならないよう、動きの頭は「右から来て止まる」か、最初から動いているものにしました。体を左右に反転させている間は、画面の字が鏡文字になるので、字を出しません。この2つは video_rules.md の決まりに足しました。
20本の冒頭は、クラウドの作業環境で2本ずつ並べて書き出しました(runall.sh)。Mac の作業用の環境はメモリが3GBで、重い書き出しは途中で止まったため、書き出しはすべてクラウド側で行い、できた mp4 を Mac に戻しています。
第52話の背景が、横長の白黒で届いた
背景画像がダウンロードフォルダに入っています
ダウンロードのフォルダから8枚を取り込み、並べて見ました。7枚はそのまま使えましたが、第52話(休憩室)だけは 2048×768 の横長の白黒でした。ショートの背景は縦長 4:5(1122×1402前後)のカラーです。同じ決まりで作り直してもらい、届いた絵の大きさを確かめてから使いました。
python3 -c "from PIL import Image; print(Image.open('$HOME/mnt/Downloads/第52話.png').size)"
このあと、届いた背景は大きさを確かめてから使う、という1行を video_rules.md に足しています。
30秒を超えた3本から、セリフを抜いた
ショートは30秒以内を目安にしています。書き出すと、第60・89・90話の3本が30秒を超えました。話の筋に要らない文と場面を抜きました。
| 話 | 抜いたもの |
|---|---|
| 第60話 再現しない | 大城さんの「週に2、3回」の場面 |
| 第89話 お手すき | 小芝先生の「言っちゃいました…」 |
| 第90話 伝言 | 「ガーン」の場面と、小芝先生の「は、はい…」 |
できあがった20本の長さです(ffprobe で測った値)。
| 話 | 長さ(秒) | 話 | 長さ(秒) |
|---|---|---|---|
| 第7話 ピンポン | 16.47 | 第63話 通行止め | 22.00 |
| 第9話 外装 | 18.87 | 第65話 道案内 | 20.30 |
| 第17話 イケボ | 25.50 | 第71話 置いてけぼり | 20.23 |
| 第23話 忍者 | 19.07 | 第72話 トイレ | 22.73 |
| 第46話 メニュー | 22.47 | 第84話 プリン | 24.93 |
| 第47話 8時間 | 19.77 | 第89話 お手すき | 28.00 |
| 第51話 自己診断 | 23.17 | 第90話 伝言 | 30.10 |
| 第52話 夜勤 | 29.40 | 第91話 心拍数 | 25.03 |
| 第60話 再現しない | 25.30 | 第92話 なかったことに | 19.97 |
| 第62話 確信度 | 27.53 | 第98話 起こさない | 27.80 |
合計は468.6秒(約7分49秒)、1本あたり平均23.4秒です。第90話は30.1秒で、0.1秒だけ超えたまま残っています。
各話のフォルダには、mp4 と一緒に YouTube に貼るタイトルと説明文(ショート_タイトルと説明文.txt)を置きました。説明文には、マンガのその話のページの URL を必ず入れています。この URL があとで、YouTube の動画と X の投稿を結びつける手がかりになります。
コマを四角で切ると、枠からはみ出した吹き出しが切れていた
20本を KIOXIA に置き、見本に第17話を見てもらったあとの指摘です。
吹き出しってもっと綺麗に切り取れないの?
見切れているけど?
これまでの切り抜きは、台本に書いた四角の範囲 crop=(左, 上, 右, 下) をそのまま切るだけでした。マンガの吹き出しは、コマの枠を突き抜けて描かれていることが多く、四角の外に出た部分は消えます。下の画像の左が、そのころの第98話です。「そういう時は起こしてよ!」の吹き出しの上半分がありません。

吹き出しを「白い所の中に、字の粒が並ぶもの」で見つける
切り抜きの範囲を吹き出しに合わせて動かすには、まず絵の中から吹き出しを見つける必要があります。マンガの絵は白黒なので、白い所(明るさ200より上)のつながりを1つずつ調べ、次の条件をすべて満たすものを吹き出しとみなしました(bubblefit.py の bubbles)。
| 条件 | 理由 |
|---|---|
面積が2,500px以上、縦横とも40px以上 | 小さな白い点を拾わない |
穴を埋めた形が、外接する四角の35%以上を占める | 細長い白(服のすき間など)を拾わない |
中に黒い粒が4つ以上あり、黒の割合が30%以下 | 字が入っている。黒すぎる所(髪・服の模様)は除く |
小さな粒(70px未満)が6つ以上、大きな粒(70px以上)がない | 字は小さな粒が並ぶ。聴診器や服のしわのような長い線があれば吹き出しではない |
| 粒の高さの中央が6px以上 | 点や網かけだけの所を除く |
中心がそろった粒(12px以内)が、行か列に4つ以上 | 字は行(横書き)か列(縦書き)にそろって並ぶ |
幅760px・高さ520px以下(幅480pxかつ高さ300pxを超えるものも除く) | コマ全体の白い背景を拾わない |
最後の条件は、試すうちに足したものです。白い背景の大きなコマに字が並んでいると、コマ全体を1つの吹き出しとして拾ってしまいました。もう1つ、ページの外に開いている吹き出し(ページの端の余白につながっている吹き出し)は、白い所がページの余白とつながって、1つの大きな白になります。ページの端に近い枠の線を黒で閉じてから探すようにしました(_closed)。
切り抜きの範囲を、吹き出しに合わせて動かす
見つけた吹き出しが切り抜きの辺にかかっていたら、範囲を動かします(fit)。
- 切り抜きの辺の近く(±45px)で、黒が4割を超える行・列をコマの枠の線とみなし、その線の外までを動かせる範囲にする
- 吹き出しの35%以上が切り抜きの中にあれば、範囲を広げて全部入れる
- 少しだけかかっているなら、その辺を狭めて外す。ただし、狭める量が切り抜きの幅(または高さ)の15%を超えるなら、狭めずに広げて入れる
- 動かすと別の吹き出しがかかることがあるので、変わらなくなるまで最大3回くり返す
コマの枠の外へは広げません。枠の外に広げると、隣のコマの絵が入るからです。
それでも枠の外にはみ出す吹き出しは、形のまま重ねる
枠を突き抜けて描かれた吹き出しは、範囲を枠いっぱいまで広げても入りきりません。そうした吹き出しは、切り抜いたコマの上に、吹き出しの形のまま重ねて出します(framed)。吹き出しの面積の19%以上が切り抜きの中にあるものだけを重ねます。数字を小さくすると、隣のコマの人の吹き出しの端まで重なって出てきたためです。
自動で外れなかった隣の吹き出しが3か所(第47・63・89話)ありました。台本の場面に、その吹き出しの中の1点を skip=[(x, y)] で書いて外しています。拾えない吹き出しは bub=[(x, y)] で足し、画面を拡大して見せる場面のように、今までどおり四角で切りたいときは fit=False と書きます(第46話の1か所)。
dict(kind="panel", crop=(21, 943, 455, 1332), skip=[(590, 1020)], ...) # 第47話
20話の59コマを、直したあとの切り抜きで数え直した結果です。
(7, 2, 2, 0)
(9, 3, 3, 0)
(17, 3, 3, 2)
(23, 4, 4, 0)
(46, 3, 2, 0)
(47, 3, 3, 1)
(51, 3, 3, 2)
(52, 4, 4, 2)
(60, 4, 4, 0)
(62, 3, 3, 3)
(63, 3, 3, 1)
(65, 3, 3, 3)
(71, 3, 3, 3)
(72, 2, 2, 2)
(84, 3, 3, 3)
(89, 3, 3, 3)
(90, 3, 3, 2)
(91, 3, 3, 3)
(92, 2, 2, 2)
(98, 2, 2, 2)
panels 59 crop moved 58 with overlay 34 fit=False 1 skip 3
左から、話・コマの数・範囲を動かしたコマ(枠の線へのそろえを含む)・吹き出しを重ねたコマです。59コマのうち34コマに、枠の外へはみ出した吹き出しがありました。 半分を超えるコマで、四角で切ると何かが欠けていたことになります。
- ねらい:マンガのセリフを、欠けずに全文読めるようにする
- 変わったこと:吹き出しを自動で見つけて、切り抜きの範囲を合わせ、入りきらないものは形のまま重ねる。59コマのうち34コマで重ねた。台本で手を入れたのは4か所(skip 3・fit=False 1)
- 見込める効果:セリフが欠けて話のオチが分からない、ということがなくなる。次からの話も、台本に四角を書くだけで同じように切れるので、1本ごとの手直しが減る
切り方を変えたので、20本とも書き出し直し、KIOXIA の mp4 を入れ替えました。
重ねた吹き出しのふちが、ガサガサで枠が2重になっていた
ちょっと切り抜きが汚いね ガサガサしているの見直して修正
上の画像の真ん中が、このときの版です。吹き出しは全文出ていますが、ふちのまわりに上のコマの絵の切れ端が付き、枠の線が2本並んでいます。拡大すると、こうなっていました。

原因は2つでした。
| 見え方 | 原因 | 直し方 |
|---|---|---|
| ふちのガサガサ | 重ねる吹き出しを、ふちの線まで入れるために形を5〜7px太らせ、まわりの絵ごと切り取っていた | 吹き出しの中(白と字)だけを取り出し、ふちの線は描き直す |
| 枠の線が2重 | 枠の線を探すとき、隣のコマの枠の線まで拾っていた | 線が2本見つかったら、このコマの内側の1本にそろえ、上に描く枠と重ねて1本にする |
ふちの描き直しは _clean_bubble です。吹き出しの形を4倍の細かさに広げ、ぼかしてから0.5で切って形をなめらかにします。形の外側1.4px・内側1.3pxを線にし、元の大きさに縮めます。4倍で作ってから縮めるので、線のふちが段々(ギザギザ)になりません。線の太さは、元の絵の吹き出しの線(約2.7px)に合わせました。最初は外2.2px・内1.6pxで作り、太すぎたので細くしています。ぼかしの強さも、1.6から2.4、2.6と上げて、ふちの小さなでこぼこが残らない所に決めました。
big = np.kron(m, np.ones((k, k), np.float32))
inner = gaussian_filter(big, 2.6 * k) > 0.5 # 中の形をなめらかに
d_out = distance_transform_edt(~inner) / k # 元の大きさでの、外側への距離
d_in = distance_transform_edt(inner) / k
ink_hi = ((~inner) & (d_out <= out_w)) | (inner & (d_in <= in_w)) # 線: ふちの外 out_w・内 in_w
枠の線のそろえは _panel と fit です。切り抜きの辺の近くで黒が4割を超える行・列が2本見つかったら、内側の1本を使います。見つかった辺は、線の外側(左と上は3px外、右と下は4px外)までを切り抜きに入れ、その上に7pxの枠を描くので、線が1本に重なります。
- ねらい:重ねた吹き出しを、元のマンガの吹き出しと同じ見た目にする
- 変わったこと:ふちの切れ端と2重の枠が消えた。20本とも書き出し直し、書き出した動画から全部のコマを取り出して並べ、ギザギザ・2重の枠・余計な吹き出しがないことを目で確かめた
- 見込める効果:動画の中で、手で貼り付けたような違和感がなくなる。マンガのコマとしてそのまま読める
この版を見てもらい、返ってきたのは「OKです!」でした。
ショートの X の投稿を、YouTube の公開予約に合わせて自動で予約した
予約投稿自動化を進めています。連携して投稿予約設定できますか?
X と LinkedIn の予約投稿の仕組みは、別の作業で組んだばかりでした(LOG376。GitHub の r2r2r-content の sns/schedule.json に投稿の日時を書くと、GitHub Actions が15分ごとに見て、時刻が来た投稿を出す)。ここに、ショートの X 投稿をつなぎます。
困るのは、YouTube へのアップロードと公開予約が手作業であることです。プログラムから動画を上げるには Google の審査が必要で、審査の前に上げた動画は非公開から変えられません。そこで、手で入れた YouTube の公開予約を読み取り、その時刻で X の投稿を予約する形にしました。
| 段 | 何が | 何をする |
|---|---|---|
| 1 | 人(YouTube Studio) | 動画を上げ、表の日時で公開予約する。説明文はそのまま貼る |
| 2 | Mac(1時間ごと) | youtube_schedule.py が自分のチャンネルの一覧を youtube_videos.json に書き出す。説明文の manga/ep{N} から話の番号(manga_ep)を拾う |
| 3 | 予定のタスク(毎日 7:47・17:47) | 一覧を取り込み、 tools/sync_youtube_shorts.py で話の番号から投稿文の json と動画を結びつけ、公開の時刻で schedule.json に入れて push する |
| 4 | GitHub Actions(15分ごと) | 時刻が来た投稿を X に出す |
youtube_schedule.py に足したのは、説明文から話の番号を拾う1行です。
"manga_ep": (lambda m: int(m.group(1)) if m else None)(re.search(r"/manga/ep(\d+)", sn.get("description", ""))),
# 説明文にあるマンガの話の番号(ショート専用のマンガのショート。2026-10-24・SNS の予約に使う)
X に出す時刻は、YouTube の公開と同じ時刻です。記事の投稿と同じ19:30に重なった日は、ショートを20:00にずらします(指示は「19:30が重なったら動画は20:00」)。YouTube 側で時刻を変えたときは、まだ投稿していない予約も次の回で合わせて直ります。公開の時刻を過ぎてから見つけたものは入れません。LinkedIn には入れていません。動画は LinkedIn に直接アップロードする決まりだからです。
投稿文は31本ぶん作り、sns/SHORT_ep{N}_sns_posts.json に置きました。すでに YouTube で予約・公開してあった11本と、新しい20本です。{url} の所が、ショートの URL に替わります。
{
"meta": {
"kind": "youtube_short",
"episode": 98,
"title": "ショート 第98話 起こさない",
"youtube_title": "「朝まで絶対に起こさないで」を、本当に一晩守りきったロボット #フィジカルai #ロボット",
"manga_url": "https://pixelstomotors.com/manga/ep98",
"url": "",
"youtube_id": ""
},
"posts": {
"x_template": "「朝まで絶対に起こさないで」を、一晩守りきったロボット。朝、承認待ちが3件…\n\n「絶対に」にも、破っていい例外がある。先に決めておきたいね。4コマ『ハコブくん』のショート\n\n{url}\n#ロボット #病院"
},
"note": "YouTube のショートの X 投稿。url と posts.x は tools/sync_youtube_shorts.py が YouTube の公開予約を見て入れる"
}
すでに YouTube で予約してあった11本は、その場で予定表に入れました。
| id | X に出す日時 |
|---|---|
| SHORT_ep41-x | 2026-10-25 08:30 |
| SHORT_ep22-x | 2026-10-26 20:00 |
| SHORT_ep24-x | 2026-10-27 20:00 |
| SHORT_ep29-x | 2026-10-28 20:00 |
| SHORT_ep43-x | 2026-10-29 20:00 |
| SHORT_ep81-x | 2026-10-30 20:00 |
| SHORT_ep82-x | 2026-11-02 20:00 |
| SHORT_ep83-x | 2026-11-03 19:30 |
| SHORT_ep85-x | 2026-11-04 20:00 |
| SHORT_ep87-x | 2026-11-05 20:00 |
| SHORT_ep88-x | 2026-11-06 20:00 |
新しい20本の YouTube の公開予約は、10/26(月)から11/24(火)までの平日12:30、1日1本にしました。祝日の11/3と11/23は空けています。表は KIOXIA の _ショート専用/公開予約の表_20261024.txt に置き、各話の説明文の先頭にも日時を書きました(全文は最後にあります)。YouTube で予約されたものから順に、X の予定表に入ります。
予定表の確かめと、記事を書いている時点での同期の結果です。
$ python3 tools/sns_autopost.py check
問題ありません。
$ python3 tools/sync_youtube_shorts.py --videos youtube_videos.json --dry-run
変わったものはありません
- ねらい:YouTube にショートを上げたら、X の投稿は何もしなくても同じ時刻に出るようにする
- 変わったこと:手でやるのは YouTube Studio での公開予約だけになった。X の31本ぶんの文が置いてあり、予約は1日2回の同期で入る
- 見込める効果:上げたのに X に出し忘れる、時刻がずれる、ということがなくなる。20本の公開が続く1か月、投稿の手間がかからない。実際に出たかは
sns/posted.jsonで確かめる
video_rules.md に足したこと(85〜87回目)
動画の作り方の決まり(_制作ベース/video_rules.md)の 9-0c に、今回の20話で決めたことを足しました。足した行の全文です。
- **2026-10-24 の20話(第7・9・17・23・46・47・51・52・60・62・63・65・71・72・84・89・90・91・92・98話)で足したこと**
- 台本は `_制作ベース/マンガショート_台本_20261024/`(spec_epNN.py と common24.py・build.py)。冒頭の動きの時刻を、声の長さから spec の中で決める(`MOTION = mot(パターン, 話, A=…)`)。`build.py anim` で動き、`build.py still` で各場面の静止画、`build.py full` で書き出し。声がまだない間は、仮の声(字数×0.14秒)で組んで確かめられる
- 字幕は `||` でページに分けられる(mkshort の add_sub。声の長さを字数で割り振る)。字幕は1ページ2行まで(3行だと y1640 より下にはみ出す)。1行14字まで・見出しは1行11字まで(checksubs.py で確かめる)
- 横に長い4コマ目は、幅1040で入れると小さい帯になる。話す人ごとに 500〜650px の幅で切り分けて2つの場面にする
- 足した動き(hakobu_motion.py): アナウンス・ギョロ(外装を外した中の目)・イケボ・忍者・メニュー・カタッ・自己診断・夜勤スロット・ピタッ・確信度・経路再計算・職員認証・置いてけぼり・案内・プリン・優先度低・伝言・心拍・消せない・見張り。最初の1秒が止まった絵にならないよう、動きの頭は _approach(右から来て止まる)か、最初から動くものにする。体が左右反転している間は画面の字が鏡文字になるので、字を出さない
- ElevenLabs の自作の声は、いまのプランでは10個が上限(2026-10-24 に 10/10)。新しい人は常連の声を借りる(中村さん=槙原さんの声・森田茂さん=田中先生の声。同じ話に本人が出ない組み合わせにする)。足した声: ハコブ_イケボ(第17話)・森田久美子
- 背景の絵は縦長 4:5 で頼む(第52話は横長の白黒で届き、作り直してもらった)。届いた絵は大きさ(1122×1402 前後)を確かめてから使う
- 30秒を超えた3本(第60・89・90話)は、話の筋に要らない文と場面を抜いて30秒以内にした
- **吹き出しを途中で切らない**(2026-10-24 エイイチの指摘「吹き出しってもっと綺麗に切り取れないの?」「見切れているけど?」)。mkshort はコマを `_制作ベース/bubblefit.py` の framed で切る: ①切り抜きの辺にかかる吹き出しは、少しだけなら辺を狭めて外し、大きく削るなら広げて入れる(コマの枠の外へは広げない)②それでもコマの枠からはみ出す吹き出し(枠を突き抜けて描かれたもの)は、形のまま上に重ねる(2割近くが切り抜きに入っているものだけ)。吹き出しは「白い所の中に、小さな字の粒が行・列にそろって並ぶもの」で見つける。ページの外に開いた吹き出しは、ページの端の枠を閉じてから探す
- 隣の人の吹き出しが重なって出るときは、spec の場面に `skip=[(x, y)]`(その吹き出しの中の点)を書く。拾えない吹き出しは `bub=[(x, y)]`、今までどおり切るだけにしたい場面は `fit=False`(画面の拡大など)
- 書き出す前に、全部のコマを並べた一覧(framed の結果)を見て、切れた吹き出しや余計な吹き出しがないか確かめる
- **ショートの X は自動で予約する**(2026-10-24 エイイチの指示「ショートも X に出す」「19:30 が重なったら動画は 20:00」)。ショートを作った会話で、X の文を r2r2r-content の `sns/SHORT_ep{N}_sns_posts.json`(`posts.x_template`、`{url}` がショートの URL に替わる・話題のタグ2つ・エイイチの話し方)に置いて push する。エイイチが YouTube Studio で公開予約すると、予定のタスク「ショートのX予約をYouTubeに合わせる」(毎日 7:47・17:47)が `youtube_videos.json` の `manga_ep`(説明文の manga/ep{N}。youtube_schedule.py が拾う)で結びつけ、公開の時刻で X の予定表に入れる。説明文の「マンガの全話」の URL は消さない。YouTube の公開予約は、ショート専用は平日 12:30(2026-10-24 の20本。表は KIOXIA の `_ショート専用/公開予約の表_20261024.txt`)。LinkedIn は手で出す
- 重ねる吹き出しは、まわりの絵ごと切り抜かない(2026-10-24 エイイチの指摘「切り抜きが汚い・ガサガサ」)。吹き出しの中(白と字)だけを取り、形をなめらかにして、ふちの線(約2.7px)を4倍の細かさで描き直してから縮める。コマの枠は、枠の線が2本(となりのコマの枠と、このコマの枠)見つかったときは内側の1本にそろえ、上に描く枠と重ねて1本にする
更新履歴に足した行です。
- 2026-10-24(87回目): 重ねた吹き出しのふちがガサガサ・枠が2重になっていた(エイイチの指摘)。bubblefit.py で吹き出しのふちを描き直し、枠を1本にそろえた。20本を書き出し直した
- 2026-10-24(86回目): マンガショートの吹き出しが途中で切れていた(エイイチの指摘)。bubblefit.py を足し、mkshort が吹き出しを切らずにコマを切るようにした。20本を書き出し直した
- 2026-10-24(85回目): ショート専用のマンガショートを20本作った(第7〜98話から20話)。9-0c に、台本の組み方(マンガショート_台本_20261024/)・字幕のページ分け(||)・横長のコマの切り分け・足した動き20個・声の上限(10個)の扱い・背景の大きさの確認・30秒以内への詰め方を足した
_制作ベース の hakobu_motion.py と マンガショート_mkshort.py は新しい版に入れ替え、前の版と video_rules.md の前の版は _制作ベース/_旧版/置き換え前_20261024_ショート20話/ に残しました。youtube_schedule.py の前の版は _制作ベース/_旧版/youtube_schedule_置き換え前_20261024.py です。
まだ確かめていないこと
- 20本の声の読み方と間は、耳で通しては確かめていない。書き出した動画のコマを並べて目で見ただけ。投稿の前に通しで見てもらう
- X の31本の文は、まだ見てもらっていない。予定表に入れるのは OK をもらったものだけ、という決まりがあるので、予約の一覧(
sns/schedule_preview.md)で確かめてもらう - 第90話は30.1秒で、30秒をわずかに超えている
- KIOXIA の
_ショート専用/_確認_20261024/に、受け渡しに使った tgz と確認用の画像が残っている(こちらからは消せない) - 公開後、ショートの「視聴を継続」の割合(作り直す前は19.0%)が上がるか。吹き出しが切れていた版と比べることはできないので、話ごとの差を見て、続けて見てもらえた話の作りを次に生かす
この記事で作った・直したファイルの全文
この記事のコード・ログ・データは、1ファイルずつ下に置いています。全文はリンク先にあります。
ファイルの一覧(10本)
01_bubblefit.py.txt ― _制作ベース/bubblefit.py・9,627字
02_mkshort.py.txt ― _制作ベース/マンガショート_mkshort.py・15,168字
03_hakobu_motion.py.txt ― _制作ベース/hakobu_motion.py・74,280字
04_voice_design.py.txt ― _制作ベース/voice_design.py・8,982字
05_sec13.json ― _ショート専用/20話のセリフ.json・3,653字
06_sec13.sh.txt ― _ショート専用/20話の声.sh・6,808字
07_youtube_schedule.py.txt ― _制作ベース/youtube_schedule.py・10,346字
08_txt.txt ― KIOXIA _ショート専用/第◯話_◯◯/ショート_タイトルと説明文.txt(20話)・8,818字
09_tools_sync_youtube_shorts.py.txt ― r2r2r-content tools/sync_youtube_shorts.py・4,220字
10_texts.py.txt ― texts.py(ショートの X の文。sns/SHORT_ep{N}_sns_posts.json を作ったクラウドの作業環境のスクリプト)・3,899字
ファイルの置き場所は、_制作ベース/ と _ショート専用/ が iCloud の PixelstoMotors/デジタルツイン学習R2R2R/動画/ の下、tools/ と docs/ と sns/ が GitHub の PixelstoMotors/r2r2r-content、書き出しの補助のスクリプトはクラウドの作業環境(/home/claude/w/)です。
_制作ベース/bubblefit.py
吹き出しを見つけ、切り抜きの範囲を合わせ、はみ出す吹き出しをふちを描き直して重ねる(新しく作った)
"""マンガのコマの切り抜きを、吹き出しを途中で切らない位置に直す(2026-10-24)
fit(comic_rgb, crop) -> 直した crop
1. 切り抜きの外側にあるコマの枠(黒い線)を探し、そこまでを動かせる範囲にする
2. 白い所の連結成分のうち、中に文字の粒が並ぶものを吹き出しとみなす
3. 切り抜きの辺にかかる吹き出しは、半分以上が中にあれば広げて入れ、少しだけなら狭めて外す
"""
import numpy as np
from scipy import ndimage_制作ベース/マンガショート_mkshort.py
台本から1本のショートを組む。字幕のページ分け(add_sub)と、bubblefit で切るコマ(framed_panel)を足した
"""マンガのショート(第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)_制作ベース/hakobu_motion.py
冒頭のハコブくんの動き。末尾の「2026-10-24 追加の20話」が今回足した20個
"""ハコブくんの短い動き(ショートの冒頭用)。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_制作ベース/マンガショート_台本_20261024/common24.py
20話の台本に共通の関数と、背景の使い回しの対応表
import os
W = os.environ.get("W24", os.path.expanduser("~/w"))
A = W + "/anim/"
C = W + "/comics/"
BG = W + "/bg/"
V = "voice/"
def hk(n, sub, **k): return dict(v=V + f"ハコブ/試し_{n:02d}.mp3", who="hk", sub=sub, **k)
def hb(name, n, sub, **k): return dict(v=V + f"{name}/試し_{n:02d}.mp3", who="hk", sub=sub, **k)
def ns(who, n, sub, **k): return dict(v=V + f"{who}/試し_{n:02d}.mp3", who="ns", sub=sub, **k)
def end(*rows, dur=4.4): return dict(kind="end", dur=dur, rows=list(rows))
def W_(text, at, x, y, size=120, color="ink", rot=0, until=99): return dict(text=text, at=at, x=x, y=y, size=size, color=color, rot=rot, until=until)
import subprocess, numpy as np
def vlen(here, rel, est=2.0):
"""声の長さ(mkshort と同じく前後の無音を落とした長さ)。まだ声がないときは est を返す"""
p = os.path.join(here, rel)
if not os.path.exists(p): return est
r = subprocess.run(["ffmpeg", "-v", "error", "-i", p, "-f", "f32le", "-ac", "1", "-ar", "48000", "-"], capture_output=True)
a = np.abs(np.frombuffer(r.stdout, np.float32))
if not len(a): return est
idx = np.where(a > a.max() * 0.06)[0]
return (idx[-1] - idx[0]) / 48000 + 0.1
# 背景(_ショート専用/背景/ の絵。使い回しはここで決める)
BGMAP = {7: "第7話.png", 9: "第9話.png", 17: "第24話.png", 23: "第67話.png", 46: "第82話.png", 47: "第48話.png",
51: "第41話.png", 52: "第52話.png", 60: "第83話.png", 62: "第19話.png", 63: "第63話.png", 65: "第22話.png",
71: "第86話.png", 72: "第72話.png", 84: "bg_empty.png", 89: "第85話.png", 90: "第90話.png", 91: "第91話.png",
92: "第20話.png", 98: "第98話.png"}
def bg(ep): return BG + BGMAP[ep]
def mot(name, ep, **kw):
"""hakobu_motion.py に渡す引数(時刻は声の長さから決めたもの)"""
kv = ",".join(f"{k}={'/'.join(f'{x:.2f}' for x in v) if isinstance(v, (tuple, list)) else (f'{v:.2f}' if isinstance(v, float) else v)}" for k, v in kw.items())
return f"{name}:{kv},bg={bg(ep)}" if kv else f"{name}:bg={bg(ep)}"
def anim(ep, name): return A + f"ep{ep}/ハコブくん_{name}.mp4"
_制作ベース/マンガショート_台本_20261024/build.py
動き・静止画・書き出しをまとめて動かす
"""使い方: python3 build.py anim|still|full [話の番号 …](番号なしで全部)"""
import os, sys, glob, importlib.util, subprocess
os.environ["W24"] = W = "/home/claude/w"
mode = sys.argv[1]; want = set(int(x) for x in sys.argv[2:])
for d in sorted(glob.glob(W + "/ep/*/")):
f = glob.glob(d + "spec_ep*.py")[0]
sp = importlib.util.spec_from_file_location("s", f); S = importlib.util.module_from_spec(sp); sp.loader.exec_module(S)
if want and S.EP not in want: continue
if mode == "anim":
ad = f"{W}/anim/ep{S.EP}"; os.makedirs(ad, exist_ok=True)
r = subprocess.run(["python3", f"{W}/hakobu_motion.py", S.MOTION], cwd=ad, capture_output=True, text=True)
print(S.NAME, (r.stdout + r.stderr).strip().replace("\n", " ")[-200:])
elif mode == "still":
import re, ast
r = subprocess.run(["python3", f"{W}/mkshort.py", f, "--still", "0.5"], capture_output=True, text=True)
sc = re.findall(r"\(([\d.]+)\)?, (?:np\.float64\()?([\d.]+)\)?, '(\w+)'\)", r.stdout.replace("np.float64(", ""))
evs = [(float(a), float(b), k) for a, b, k in re.findall(r"\(([\d.]+)\)?, ([\d.]+)\)?, '(\w+)'", r.stdout.replace("np.float64(", "").replace(")", ""))]
ts = [evs[0][1] * 0.6, evs[0][1] - 0.3] + [min(b - 0.1, a + 1.2) for a, b, k in evs[1:]]
ts = ",".join(f"{t:.2f}" for t in ts)
for g in glob.glob(d + "still_*.png"): os.remove(g)
r = subprocess.run(["python3", f"{W}/mkshort.py", f, "--still", ts], capture_output=True, text=True)
print(S.NAME, (r.stdout + r.stderr).strip()[-300:])
else:
r = subprocess.run(["python3", f"{W}/mkshort.py", f], capture_output=True, text=True)
print(S.NAME, (r.stdout + r.stderr).strip()[-300:])
_制作ベース/マンガショート_台本_20261024/setup_ep.py
話ごとの作業フォルダを用意する
import os, sys, glob, importlib.util, re, subprocess, shutil
os.environ["W24"] = "/home/claude/w"
W = "/home/claude/w"
for f in sorted(glob.glob(W + "/specs/spec_ep*.py")):
src = open(f).read(); name = re.search(r'NAME, EP = "([^"]+)"', src).group(1)
d = f"{W}/ep/{name}"; os.makedirs(d, exist_ok=True)
shutil.copy(f, d)
for k in ("se", "base"):
if not os.path.exists(f"{d}/{k}"): os.symlink(f"{W}/{k}", f"{d}/{k}")
os.makedirs(f"{d}/voice", exist_ok=True)
print("ok")
_制作ベース/マンガショート_台本_20261024/checksubs.py
見出しと字幕の字数・行数を確かめる
import os, sys, glob, importlib.util
os.environ.setdefault("W24", "/home/claude/w")
bad = 0
for f in sorted(glob.glob(os.path.join(os.path.dirname(os.path.abspath(__file__)), "specs/spec_ep*.py"))):
sp = importlib.util.spec_from_file_location("s", f); S = importlib.util.module_from_spec(sp); sp.loader.exec_module(S)
for line in S.HEAD:
n = len(line.replace("【", "").replace("】", ""))
if n > 11: print(f"{S.NAME} 見出し {n}字: {line}"); bad += 1
for sc in S.SCENES:
for l in sc.get("lines", []):
for pg in l["sub"].split("||"):
rows = pg.split("|")
for r in rows:
n = len(r.replace("【", "").replace("】", ""))
if n > 14: print(f"{S.NAME} 字幕 {n}字: {r}"); bad += 1
if len(rows) > 3 or (len(rows) == 1 and len(pg.replace('【','').replace('】','')) > 13):
print(f"{S.NAME} 行数 {len(rows)}: {pg}"); bad += 1
for r in sc.get("rows", []):
w = len(r[0]) * r[1]
if w > 1000: print(f"{S.NAME} まとめ 幅{w}: {r[0]}"); bad += 1
print("要確認:", bad)
_制作ベース/マンガショート_台本_20261024/spec_ep07.py
import os, sys; sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)) + "/..")
sys.path.insert(0, os.environ.get("W24", os.path.expanduser("~/w")) + "/specs"); from common24 import *
HERE = os.path.dirname(os.path.abspath(__file__)); d = lambda l, e=2.0: vlen(HERE, l["v"], e)
NAME, EP = "第7話_ピンポン", 7
HEAD = ["深刻な告知の最中に", "ロボが【大声で】"]
COMIC = C + "ep07.png"
P = dict(L=(19, 437, 547, 865), R=(554, 437, 1062, 865), B=(19, 872, 1062, 1326))
l1 = ns("田中", 1, "残念ながら、検査の結果が|思わしくなく…。", at=0.15)
a = 0.15 + d(l1, 3.0) + 0.3 # アナウンスの始まり
l2 = hk(1, "【ピンポンパンポーン!】||検便容器を、|回収してまいりました!", at=a)
l2d = d(l2, 4.5)
B = a + 1.5 # 画面が「検便容器ヲ…」に替わる
DUR = a + l2d + 0.5
MOTION = mot("アナウンス", EP, A=a, B=B, dur=DUR)
SCENES = [
dict(kind="op", video=anim(EP, "アナウンス"), dur=DUR,
bubble=["残念ながら、", "検査の結果が…"], bubble_until=a,
lines=[l1, l2],
words=[W_("ピンポン", a, 300, 330, 120, "orange", -6, a + 0.9), W_("パンポーン!", a + 0.6, 720, 250, 120, "orange", 6, a + 1.6),
W_("ガナガ!!", a + 1.7, 310, 560, 130, "ink", -8, DUR)],
se=[dict(at=0.05, f="base/se_whoosh.wav", db=-14), dict(at=a, f="base/se_ding.wav", db=-8)]),
dict(kind="panel", crop=P["L"], min=1.7, words=[W_("…っ", 0.2, 820, 160, 110, "ink", 4)]),
dict(kind="panel", crop=(19, 872, 600, 1326), lines=[hk(2, "タスク、完了。")],
slam=dict(text="【タスク完了】", at=0.2, y=170, size=150)),
end(("伝える中身は正しくても", 66, "ink", 0.0), ("場の空気しだいで", 74, "ink", 0.25),
("最悪の一言になる", 104, "orange", 0.9), ("(音声で何を言うかは、まわりを見て決める)", 38, "gray", 1.8)),
]
_制作ベース/マンガショート_台本_20261024/spec_ep09.py
import os, sys
sys.path.insert(0, os.environ.get("W24", os.path.expanduser("~/w")) + "/specs"); from common24 import *
HERE = os.path.dirname(os.path.abspath(__file__)); d = lambda l, e=2.0: vlen(HERE, l["v"], e)
NAME, EP = "第9話_外装", 9
HEAD = ["外装を外したら", "ロボが【ギョロッ】"]
COMIC = C + "ep09.png"
P = dict(L=(17, 443, 552, 887), B=(17, 894, 1063, 1330))
l1 = ns("高杉", 1, "ハコブくん、今日も|お疲れ様〜。||寝てるかな?", at=0.15)
G = 0.15 + d(l1, 3.0) + 0.4 # ギョロ
DUR = G + 1.8
MOTION = mot("ギョロ", EP, G=G, dur=DUR)
SCENES = [
dict(kind="op", video=anim(EP, "ギョロ"), dur=DUR,
bubble=["ハコブくん、", "寝てるかな?"], bubble_until=G,
lines=[l1],
words=[W_("ギョロ…", G, 720, 300, 170, "ink", -6)],
se=[dict(at=G, f="se/se_don.mp3", db=-8)]),
dict(kind="panel", crop=P["L"], min=1.8, words=[W_("そーっと…", 0.2, 330, 120, 90, "ink", -4)]),
dict(kind="panel", crop=(17, 894, 560, 1330), min=1.5, words=[W_("ヒィッ", 0.15, 300, 140, 140, "orange", -8)]),
dict(kind="panel", crop=(540, 894, 1063, 1330), lines=[ns("槙原", 1, "あ、外装、|外してるだけですよ。"), hk(1, "タスク、完了。")]),
end(("中身が見えた瞬間", 74, "ink", 0.0), ("かわいいは", 74, "ink", 0.25),
("一瞬で消える", 104, "orange", 0.9), ("(外装は、信頼を守る器)", 40, "gray", 1.8)),
]
_制作ベース/マンガショート_台本_20261024/spec_ep17.py
import os, sys
sys.path.insert(0, os.environ.get("W24", os.path.expanduser("~/w")) + "/specs"); from common24 import *
HERE = os.path.dirname(os.path.abspath(__file__)); d = lambda l, e=2.0: vlen(HERE, l["v"], e)
NAME, EP = "第17話_イケボ", 17
HEAD = ["アップデートで", "ロボが【イケボ】に"]
COMIC = C + "ep17.png"
P = dict(T=(20, 20, 700, 460), S=(380, 880, 1060, 1350), Z=(20, 1000, 560, 1350))
l1 = ns("大城", 2, "ハコブくん、こっち来て!", at=0.15)
K = 0.15 + d(l1, 1.6) + 1.0 # 決める(ゆっくり近づいてから)
l2 = hb("ハコブ_イケボ", 1, "【御意。】", at=K)
l3 = hb("ハコブ_イケボ", 2, "ただいま参上|つかまつった、姫君。", at=K + d(l2, 1.0) + 0.35)
DUR = l3["at"] + d(l3, 2.6) + 0.5
MOTION = mot("イケボ", EP, K=K, dur=DUR)
SCENES = [
dict(kind="op", video=anim(EP, "イケボ"), dur=DUR,
bubble=["ハコブくん、", "こっち来て!"], bubble_until=K,
lines=[l1, l2, l3],
words=[W_("キラーン", K + 0.15, 760, 200, 120, "orange", 8)],
se=[dict(at=K + 0.1, f="base/se_ding.wav", db=-8)]),
dict(kind="panel", crop=P["T"], lines=[ns("寺島", 2, "メーカーいわく、【親しみやすさ】|を重視した||アップデートだそうです。")]),
dict(kind="panel", crop=P["S"], lines=[ns("大城", 3, "キャラが濃すぎるわ!|前のカタコトに戻して!")],
slam=dict(text="キャラが濃すぎる!!", at=0.15, y=150, size=120)),
dict(kind="panel", crop=P["Z"], lines=[hb("ハコブ_イケボ", 3, "……御意。|姫君の、望むままに……。")]),
end(("親しみやすさは", 74, "ink", 0.0), ("キャラの濃さ", 74, "ink", 0.25),
("じゃない", 104, "orange", 0.9), ("(現場が求めるのは、いつも同じにふるまう道具)", 36, "gray", 1.8)),
]
_制作ベース/マンガショート_台本_20261024/spec_ep23.py
import os, sys
sys.path.insert(0, os.environ.get("W24", os.path.expanduser("~/w")) + "/specs"); from common24 import *
HERE = os.path.dirname(os.path.abspath(__file__)); d = lambda l, e=2.0: vlen(HERE, l["v"], e)
NAME, EP = "第23話_忍者", 23
HEAD = ["静音モードにしたら", "ロボが【忍者】に"]
COMIC = C + "ep23.png"
P = dict(L=(22, 437, 518, 939), R=(528, 437, 1058, 939), B=(21, 952, 1058, 1325))
S = 3.1
DUR = S + 0.8
MOTION = mot("忍者", EP, S=S, dur=DUR)
SCENES = [
dict(kind="op", video=anim(EP, "忍者"), dur=DUR,
words=[W_("シーン…", 0.2, 760, 180, 110, "ink", 4), W_("スーッ", 1.2, 260, 520, 120, "ink", -8, S)],
se=[dict(at=0.05, f="base/se_whoosh.wav", db=-26)]),
dict(kind="panel", crop=P["L"], min=1.6, words=[W_("(無音)", 0.2, 300, 130, 90, "gray", -4)]),
dict(kind="panel", crop=P["R"], lines=[ns("大城", 1, "【ひっ!】")], min=1.6,
slam=dict(text="ひっ!", at=0.1, y=180, size=170)),
dict(kind="panel", crop=(21, 952, 560, 1325), lines=[ns("大城", 2, "静かすぎて、心臓に悪いわ!|少しは音を出して!")]),
dict(kind="panel", crop=(480, 952, 1058, 1325), lines=[hk(1, "忍者モード、|解除します。")]),
end(("静かすぎるロボットは", 70, "ink", 0.0), ("かえって", 74, "ink", 0.25),
("こわい", 120, "orange", 0.9), ("(気配も、仕様のうち)", 40, "gray", 1.8)),
]
_制作ベース/マンガショート_台本_20261024/spec_ep46.py
import os, sys
sys.path.insert(0, os.environ.get("W24", os.path.expanduser("~/w")) + "/specs"); from common24 import *
HERE = os.path.dirname(os.path.abspath(__file__)); d = lambda l, e=2.0: vlen(HERE, l["v"], e)
NAME, EP = "第46話_メニュー", 46
HEAD = ["音量を下げたいだけ", "なのに【メニューの森】"]
COMIC = C + "ep46.png"
P = dict(L=(25, 379, 562, 884), R=(574, 379, 1057, 884), B=(25, 901, 1057, 1323))
l1 = ns("大城", 1, "ハコブくんの音量、|夜勤用に下げておきたい。", at=0.15)
P0 = 0.15 + d(l1, 2.6) + 0.2
STEP = 0.55
Cl = P0 + 5 * STEP + 0.3 # ナースコール
DUR = Cl + 1.4
MOTION = mot("メニュー", EP, P0=P0, STEP=STEP, C=Cl, dur=DUR)
SCENES = [
dict(kind="op", video=anim(EP, "メニュー"), dur=DUR,
bubble=["音量、", "下げておきたい"], bubble_until=P0,
lines=[l1],
words=[W_("ポチ", P0 + i * STEP, 200 + (i % 2) * 650, 300 + i * 70, 90, "ink", -6 + 12 * (i % 2), P0 + i * STEP + 0.5) for i in range(5)]
+ [W_("ピーンポーン", Cl, 540, 220, 130, "orange", -6)],
se=[dict(at=P0 + i * STEP, f="se/se_pechin.wav", db=-18) for i in range(5)] + [dict(at=Cl, f="base/se_ding.wav", db=-4)]),
dict(kind="panel", crop=P["L"], fit=False, min=1.8, words=[W_("まだ奥…", 0.2, 760, 120, 90, "orange", 5)]),
dict(kind="panel", crop=(25, 901, 520, 1323), lines=[ns("大城", 3, "もう、トップ画面に||「よく使う設定」を|3つだけ出して!")]),
dict(kind="panel", crop=(500, 901, 1057, 1323), lines=[hk(1, "同意します。|深すぎました。")]),
end(("よく使う設定ほど", 74, "ink", 0.0), ("手前に", 74, "ink", 0.25),
("置く", 120, "orange", 0.9), ("(急いでいる人は、深い階層までたどり着けない)", 36, "gray", 1.8)),
]
_制作ベース/マンガショート_台本_20261024/spec_ep47.py
import os, sys
sys.path.insert(0, os.environ.get("W24", os.path.expanduser("~/w")) + "/specs"); from common24 import *
HERE = os.path.dirname(os.path.abspath(__file__)); d = lambda l, e=2.0: vlen(HERE, l["v"], e)
NAME, EP = "第47話_8時間", 47
HEAD = ["8時間ずっと", "【警告】していたのに"]
COMIC = C + "ep47.png"
P = dict(T=(22, 21, 1064, 354), R=(540, 363, 1064, 933), B=(21, 943, 1064, 1332))
l1 = hk(1, "右車輪の、|メンテナンスを推奨します。", at=0.3, db=-8)
t2 = 0.3 + d(l1, 2.8) + 0.25
l2 = ns("寺島", 1, "あ、後でね。", at=t2)
DUR = t2 + d(l2, 1.0) + 1.2
K = (1.5, t2 + 0.6, t2 + 1.3, DUR - 0.5)
MOTION = mot("カタッ", EP, K=K, dur=DUR)
SCENES = [
dict(kind="op", video=anim(EP, "カタッ"), dur=DUR,
bubbles=[dict(lines=["あ、後でね"], frm=t2, until=DUR, side="R", y=120)],
lines=[l1, l2],
words=[W_("カタッ", k, 230 + 560 * (i % 2), 560 - 60 * i, 110, "ink", -8 + 16 * (i % 2), k + 0.6) for i, k in enumerate(K)],
se=[dict(at=k, f="se/se_yusa.wav", db=-12) for k in K]),
dict(kind="panel", crop=P["R"], lines=[ns("大城", 1, "あら、なんか今日、|変だと思ってた。")]),
dict(kind="panel", crop=(300, 943, 820, 1332), lines=[hk(2, "【8時間前から】、|告知しておりました。")],
slam=dict(text="8時間前から", at=0.2, y=150, size=130)),
dict(kind="panel", crop=(21, 943, 455, 1332), skip=[(590, 1020)], lines=[ns("大城", 2, "ごめん、|表示の仕方を変えよう。")]),
end(("表示しただけでは", 74, "ink", 0.0), ("伝わったことに", 74, "ink", 0.25),
("ならない", 120, "orange", 0.9), ("(届いたかどうかまで、設計する)", 40, "gray", 1.8)),
]
_制作ベース/マンガショート_台本_20261024/spec_ep51.py
import os, sys
sys.path.insert(0, os.environ.get("W24", os.path.expanduser("~/w")) + "/specs"); from common24 import *
HERE = os.path.dirname(os.path.abspath(__file__)); d = lambda l, e=2.0: vlen(HERE, l["v"], e)
NAME, EP = "第51話_自己診断", 51
HEAD = ["目が悪くなったのに", "診断は【全部正常】"]
COMIC = C + "ep51.png"
P = dict(R=(550, 424, 1060, 862), B=(20, 880, 1060, 1326))
l1 = ns("大城", 1, "ハコブくん、|目、悪くなった?", at=0.15)
S = 0.15 + d(l1, 1.8) + 0.2
R = S + 1.3
l2 = hk(1, "自己診断結果、|【全機能、正常】。", at=R)
DUR = R + d(l2, 2.2) + 0.6
MOTION = mot("自己診断", EP, S=S, R=R, dur=DUR)
SCENES = [
dict(kind="op", video=anim(EP, "自己診断"), dur=DUR,
bubble=["ハコブくん、", "目、悪くなった?"], bubble_until=S,
lines=[l1, l2],
words=[W_("ドヤッ", R + 0.3, 800, 260, 120, "orange", 8)],
se=[dict(at=S, f="se/se_boot.wav", db=-14), dict(at=R, f="base/se_ding.wav", db=-10)]),
dict(kind="panel", crop=P["R"], min=1.9, words=[W_("レンズに", 0.15, 300, 110, 90, "ink", -4), W_("ホコリ", 0.5, 300, 220, 130, "orange", -4)]),
dict(kind="panel", crop=(20, 880, 560, 1326), lines=[hk(2, "視界不良を、|検知できませんでした。"), hk(3, "自分の目は、|自分で見えない。")]),
dict(kind="panel", crop=(540, 880, 1060, 1326), lines=[ns("大城", 2, "私たちが、|定期的に拭くわね。")]),
end(("センサーの汚れは", 74, "ink", 0.0), ("そのセンサーでは", 74, "ink", 0.25),
("見つけられない", 104, "orange", 0.9), ("(外から点検する人が要る)", 40, "gray", 1.8)),
]
_制作ベース/マンガショート_台本_20261024/spec_ep52.py
import os, sys
sys.path.insert(0, os.environ.get("W24", os.path.expanduser("~/w")) + "/specs"); from common24 import *
HERE = os.path.dirname(os.path.abspath(__file__)); d = lambda l, e=2.0: vlen(HERE, l["v"], e)
NAME, EP = "第52話_夜勤", 52
HEAD = ["気をきかせたロボが", "【シフト表】を見ていた"]
COMIC = C + "ep52.png"
P = dict(L=(35, 362, 609, 878), R=(618, 366, 1065, 878), B=(35, 897, 1047, 1320))
l1 = hk(1, "大城様、今月の夜勤は、|過去6カ月平均より、||【40パーセント】多いです。", at=0.9)
L1 = d(l1, 5.0)
S0, S1 = 1.3, 0.9 + L1 * 0.78 # 「40パーセント」のあたりで止まる
DUR = 0.9 + L1 + 0.7
MOTION = mot("夜勤スロット", EP, S0=S0, S1=S1, dur=DUR)
SCENES = [
dict(kind="op", video=anim(EP, "夜勤スロット"), dur=DUR,
lines=[l1],
words=[W_("+40%", S1, 780, 260, 170, "orange", 8)],
se=[dict(at=S0, f="se/se_spin.wav", db=-14), dict(at=S1, f="se/se_jaran.wav", db=-8)]),
dict(kind="panel", crop=P["L"], lines=[ns("大城", 1, "ありがた…いけど、|どこまで見るの?"), hk(2, "シフト表を、|閲覧しました。")]),
dict(kind="panel", crop=P["R"], lines=[ns("大城", 2, "悪気はないのよね…。|でも、線引きしようね。")]),
dict(kind="panel", crop=(35, 897, 560, 1320), lines=[ns("大城", 3, "私のデータ見たら、|見たって先に言って。")]),
dict(kind="panel", crop=(520, 897, 1047, 1320), lines=[hk(3, "以後、参照する前に、|告知します。")]),
end(("気づかいでも", 74, "ink", 0.0), ("どこで知ったかが", 74, "ink", 0.25),
("見えないとこわい", 104, "orange", 0.9), ("(データを見る前に、ひとこと言う)", 40, "gray", 1.8)),
]
_制作ベース/マンガショート_台本_20261024/spec_ep60.py
import os, sys
sys.path.insert(0, os.environ.get("W24", os.path.expanduser("~/w")) + "/specs"); from common24 import *
HERE = os.path.dirname(os.path.abspath(__file__)); d = lambda l, e=2.0: vlen(HERE, l["v"], e)
NAME, EP = "第60話_再現しない", 60
HEAD = ["見張っていると", "【バグが起きない】"]
COMIC = C + "ep60.png"
P = dict(T=(21, 29, 1055, 451), L=(21, 459, 501, 916), B=(21, 924, 1055, 1321))
Pp = 2.6
l1 = ns("大城", 2, "白石さーん!|戻ってきて!", at=Pp + 0.5)
DUR = l1["at"] + d(l1, 1.8) + 0.4
MOTION = mot("ピタッ", EP, P=Pp, dur=DUR)
SCENES = [
dict(kind="op", video=anim(EP, "ピタッ"), dur=DUR,
lines=[l1],
words=[W_("ピタッ", Pp, 540, 240, 190, "ink", -6)],
se=[dict(at=Pp, f="se/se_skid.wav", db=-8)]),
dict(kind="panel", crop=(450, 29, 1055, 451), lines=[ns("白石", 1, "再現条件、|調べに来ました!")]),
dict(kind="panel", crop=P["L"], lines=[ns("白石", 2, "…起きないですね。")],
words=[W_("2日張り込み", 0.15, 300, 840, 90, "orange", -4)]),
dict(kind="panel", crop=(21, 924, 560, 1321), lines=[ns("白石", 3, "廊下のWi-Fiの|切り替えの時の挙動でした…。||次回も録画、|お願いします!")]),
dict(kind="panel", crop=(330, 924, 800, 1321), lines=[hk(1, "現場の目が、|最強のセンサーです。")]),
end(("見ていない時にだけ", 70, "ink", 0.0), ("起きる不具合は", 74, "ink", 0.25),
("現場の目で捕まえる", 100, "orange", 0.9), ("(録画をたのめる関係を作っておく)", 40, "gray", 1.8)),
]
_制作ベース/マンガショート_台本_20261024/spec_ep62.py
import os, sys
sys.path.insert(0, os.environ.get("W24", os.path.expanduser("~/w")) + "/specs"); from common24 import *
HERE = os.path.dirname(os.path.abspath(__file__)); d = lambda l, e=2.0: vlen(HERE, l["v"], e)
NAME, EP = "第62話_確信度", 62
HEAD = ["「了解」と言ったのに", "自信は【62%】だった"]
COMIC = C + "ep62.png"
P = dict(R=(552, 424, 1060, 862), B=(20, 882, 1060, 1325))
l1 = ns("大城", 1, "ノルバスクを、|三番の保管庫まで、お願い。", at=0.15)
t2 = 0.15 + d(l1, 2.6) + 0.25
l2 = hk(1, "了解しました。", at=t2)
D0 = t2 + d(l2, 1.0) + 0.1
D1, Q = D0 + 0.7, D0 + 1.0
B0 = D1 + 1.3; B1 = B0 + 1.0
DUR = B1 + 0.5
MOTION = mot("確信度", EP, D0=D0, D1=D1, Q=Q, B0=B0, B1=B1, dur=DUR)
SCENES = [
dict(kind="op", video=anim(EP, "確信度"), dur=DUR,
bubble=["ノルバスクを、", "三番の保管庫まで"], bubble_until=t2,
lines=[l1, l2],
words=[W_("ダッ", D0, 300, 520, 120, "ink", -8, D1 + 0.2), W_("確信度62%", Q, 540, 230, 120, "orange", -5)],
se=[dict(at=D0, f="se/se_dash.mp3", db=-8), dict(at=Q, f="se/se_pikon.mp3", db=-10)]),
dict(kind="panel", crop=P["R"], lines=[hk(2, "確認します。|薬剤名を、もう一度。"), ns("大城", 2, "なんで最初に言わないの!|二度手間よ!")]),
dict(kind="panel", crop=(60, 882, 600, 1325), lines=[hk(3, "聞き取れました。|【確信度、低】。")]),
dict(kind="panel", crop=(460, 882, 1000, 1325), lines=[ns("大城", 3, "その方がいいの。||迷ってるなら、|迷ってる顔して。")]),
end(("まちがえる前に", 74, "ink", 0.0), ("「自信がない」と", 74, "ink", 0.25),
("言える機械", 110, "orange", 0.9), ("(迷いを、外に見せる設計)", 40, "gray", 1.8)),
]
_制作ベース/マンガショート_台本_20261024/spec_ep63.py
import os, sys
sys.path.insert(0, os.environ.get("W24", os.path.expanduser("~/w")) + "/specs"); from common24 import *
HERE = os.path.dirname(os.path.abspath(__file__)); d = lambda l, e=2.0: vlen(HERE, l["v"], e)
NAME, EP = "第63話_通行止め", 63
HEAD = ["地図にない壁の前で", "【再計算】が止まらない"]
COMIC = C + "ep63.png"
P = dict(T=(20, 20, 1060, 400), R=(540, 424, 1060, 864), B=(20, 884, 1060, 1325))
l1 = hk(1, "経路、再計算。", at=0.4)
l2 = hk(2, "経路、再計算。|経路、再計算。", at=0.4 + d(l1, 1.3) + 0.15)
STOP = l2["at"] + d(l2, 2.4) + 0.1
F = tuple(round(x * STOP / 3.72, 2) for x in (0.35, 1.5, 2.1, 2.5, 2.8, 3.05, 3.25, 3.42, 3.56))
DUR = STOP + 1.4
MOTION = mot("経路再計算", EP, F=F, STOP=STOP, dur=DUR)
SCENES = [
dict(kind="op", video=anim(EP, "経路再計算"), dur=DUR,
lines=[l1, l2],
words=[W_("ウロウロ", 0.5, 760, 200, 110, "ink", 6, STOP), W_("ピタッ…", STOP, 300, 240, 110, "ink", -6)]),
dict(kind="panel", crop=(20, 20, 568, 400), skip=[(690, 400)], min=1.8, words=[W_("昨日から通行止め", 0.15, 540, 90, 90, "orange", -3)]),
dict(kind="panel", crop=P["R"], lines=[ns("高杉", 1, "そこ、昨日から|壁なのよ。"), hk(3, "掲示は、読めます。||地図は、|更新されていません。")]),
dict(kind="panel", crop=(300, 884, 1060, 1325), min=2.0, words=[W_("手順に1行", 0.2, 560, 110, 120, "orange", -5)]),
end(("通路を変えたら", 74, "ink", 0.0), ("ロボの地図も", 74, "ink", 0.25),
("同じ日に直す", 110, "orange", 0.9), ("(善意ではなく、作業の手順に入れる)", 38, "gray", 1.8)),
]
_制作ベース/マンガショート_台本_20261024/spec_ep65.py
import os, sys
sys.path.insert(0, os.environ.get("W24", os.path.expanduser("~/w")) + "/specs"); from common24 import *
HERE = os.path.dirname(os.path.abspath(__file__)); d = lambda l, e=2.0: vlen(HERE, l["v"], e)
NAME, EP = "第65話_道案内", 65
HEAD = ["道を聞いただけで", "【職員認証】を要求"]
COMIC = C + "ep65.png"
P = dict(R=(576, 383, 1051, 869), B=(31, 877, 1051, 1315))
l1 = ns("森田茂", 1, "すいません、|304号室は、どっちですか。", at=1.2)
Aa = 1.2 + d(l1, 2.4) + 0.25
l2 = hk(1, "【職員認証が、必要です】。", at=Aa)
DUR = Aa + d(l2, 1.8) + 0.8
MOTION = mot("職員認証", EP, A=Aa, dur=DUR)
SCENES = [
dict(kind="op", video=anim(EP, "職員認証"), dur=DUR,
bubble=["すいません、", "304号室は?"], bubble_from=1.2, bubble_until=Aa,
lines=[l1, l2],
words=[W_("ブブー", Aa, 780, 260, 130, "orange", 8, Aa + 1.2)],
se=[dict(at=Aa, f="se/se_buzz.wav", db=-10)]),
dict(kind="panel", crop=P["R"], min=1.7, words=[W_("……", 0.2, 250, 120, 120, "ink", 0)]),
dict(kind="panel", crop=(31, 877, 560, 1315), lines=[ns("大城", 1, "道案内くらい、|誰にでも答えていいと思うの。")]),
dict(kind="panel", crop=(420, 877, 1051, 1315), lines=[hk(2, "権限を、|機能ごとに分けます。")]),
end(("守ることと", 74, "ink", 0.0), ("閉じることは", 74, "ink", 0.25),
("ちがう", 120, "orange", 0.9), ("(道案内に、職員の鍵はいらない)", 40, "gray", 1.8)),
]
_制作ベース/マンガショート_台本_20261024/spec_ep71.py
import os, sys
sys.path.insert(0, os.environ.get("W24", os.path.expanduser("~/w")) + "/specs"); from common24 import *
HERE = os.path.dirname(os.path.abspath(__file__)); d = lambda l, e=2.0: vlen(HERE, l["v"], e)
NAME, EP = "第71話_置いてけぼり", 71
HEAD = ["道案内をしたら", "【誰もついてこない】"]
COMIC = C + "ep71.png"
P = dict(L=(20, 424, 527, 862), R=(552, 424, 1060, 862), B=(20, 882, 1060, 1325))
l1 = hk(1, "こちらです。", at=0.95)
Z0 = 0.95 + d(l1, 0.9) + 0.1
DUR = Z0 + 2.0
MOTION = mot("置いてけぼり", EP, Z0=Z0, Z1=Z0 + 0.8, dur=DUR)
SCENES = [
dict(kind="op", video=anim(EP, "置いてけぼり"), dur=DUR,
lines=[l1],
words=[W_("ズギュン", Z0 + 0.1, 700, 260, 150, "ink", -8)],
se=[dict(at=Z0, f="se/se_dash.mp3", db=-6)]),
dict(kind="panel", crop=P["L"], lines=[ns("森田久美子", 1, "あら、|ロボットさんは?")]),
dict(kind="panel", crop=P["R"], lines=[ns("森田久美子", 2, "速いのよ、あなた。||こっちは荷物|持ってるんだから。"), hk(2, "経路は、最短です。")]),
dict(kind="panel", crop=(180, 882, 860, 1325), lines=[hk(3, "合わせます。"), ns("森田久美子", 3, "そう、それでいいの。")]),
end(("案内の速さは", 74, "ink", 0.0), ("案内される人が", 74, "ink", 0.25),
("決める", 120, "orange", 0.9), ("(荷物・足もと・歩く速さに合わせる)", 38, "gray", 1.8)),
]
_制作ベース/マンガショート_台本_20261024/spec_ep72.py
import os, sys
sys.path.insert(0, os.environ.get("W24", os.path.expanduser("~/w")) + "/specs"); from common24 import *
HERE = os.path.dirname(os.path.abspath(__file__)); d = lambda l, e=2.0: vlen(HERE, l["v"], e)
NAME, EP = "第72話_トイレ", 72
HEAD = ["トイレに行きたいのに", "【案内が止まらない】"]
COMIC = C + "ep72.png"
P = dict(R=(615, 377, 1049, 846), B=(380, 863, 1049, 1324))
l1 = hk(1, "目的地、405号室。|案内中。", at=0.2)
t2 = 0.2 + d(l1, 2.0) + 0.2
l2 = ns("中村", 1, "ちょっと、トイレどこ?", at=t2)
t3 = t2 + d(l2, 1.4) + 0.2
l3 = hk(2, "【経路を、継続します】。", at=t3)
t4 = t3 + d(l3, 1.6) + 0.15
l4 = hk(3, "【経路を、継続します】。", at=t4)
DUR = t4 + d(l4, 1.6) + 0.5
MOTION = mot("案内", EP, A=t3, STEP=round((DUR - t3) / 9, 3), dur=DUR)
SCENES = [
dict(kind="op", video=anim(EP, "案内"), dur=DUR,
bubbles=[dict(lines=["ちょっと、", "トイレどこ?"], frm=t2, until=DUR, side="R", y=110)],
lines=[l1, l2, l3, l4],
words=[W_("スタスタ", t3 + 0.2, 220, 420, 110, "ink", -8)]),
dict(kind="panel", crop=P["R"], lines=[ns("中村", 2, "置いてかれるかと|思った。")]),
dict(kind="panel", crop=P["B"], lines=[ns("大城", 1, "案内中でも、|寄り道は入るのよ。"), ns("白石", 1, "あ、トイレ…。|盲点でした。")]),
end(("人は", 74, "ink", 0.0), ("まっすぐには", 74, "ink", 0.25),
("歩かない", 120, "orange", 0.9), ("(寄り道と再開を、最初から設計する)", 38, "gray", 1.8)),
]
_制作ベース/マンガショート_台本_20261024/spec_ep84.py
import os, sys
sys.path.insert(0, os.environ.get("W24", os.path.expanduser("~/w")) + "/specs"); from common24 import *
HERE = os.path.dirname(os.path.abspath(__file__)); d = lambda l, e=2.0: vlen(HERE, l["v"], e)
NAME, EP = "第84話_プリン", 84
HEAD = ["声まねで頼んだら", "【本物の先生】に届いた"]
COMIC = C + "ep84.png"
P = dict(R=(550, 424, 1060, 862), B=(20, 882, 1060, 1325))
l1 = ns("田中", 1, "ハコブくん、プリンを|持ってきてくれ。至急だ。", at=0.15)
H_ = 0.15 + d(l1, 2.8) + 0.15
l2 = hk(1, "田中先生の声と、一致。|了解しました。", at=H_ + 0.2)
D0 = l2["at"] + d(l2, 2.4) + 0.1
D1 = D0 + 0.7
DUR = D1 + 1.6
MOTION = mot("プリン", EP, H=H_, D0=D0, D1=D1, dur=DUR)
SCENES = [
dict(kind="op", video=anim(EP, "プリン"), dur=DUR,
bubble=["プリンを持ってきて", "くれ。至急だ"], bubble_until=H_,
lines=[l1, l2],
words=[W_("!", H_, 820, 220, 200, "orange", 8, D0), W_("ギューン", D0, 300, 520, 130, "ink", -8, D1 + 0.4)],
se=[dict(at=H_, f="se/se_pikon.mp3", db=-8), dict(at=D0, f="se/se_dash.mp3", db=-6), dict(at=D1, f="se/se_skid.wav", db=-10)]),
dict(kind="panel", crop=P["R"], lines=[hk(2, "ご注文の、|プリンです。"), ns("田中", 2, "【頼んでない】。")]),
dict(kind="panel", crop=(480, 882, 1060, 1325), lines=[ns("田中", 3, "…俺の声、|そんなに似てたか?")]),
dict(kind="panel", crop=(20, 882, 640, 1325), lines=[ns("中村", 1, "けっこう、|自信あります。"), hk(3, "以後、声だけでは、|受け付けません。")]),
end(("声が似ていることは", 70, "ink", 0.0), ("本人の証明に", 74, "ink", 0.25),
("ならない", 120, "orange", 0.9), ("(受け付けることと、許可することは別)", 38, "gray", 1.8)),
]
_制作ベース/マンガショート_台本_20261024/spec_ep89.py
import os, sys
sys.path.insert(0, os.environ.get("W24", os.path.expanduser("~/w")) + "/specs"); from common24 import *
HERE = os.path.dirname(os.path.abspath(__file__)); d = lambda l, e=2.0: vlen(HERE, l["v"], e)
NAME, EP = "第89話_お手すき", 89
HEAD = ["遠慮して頼んだら", "【本当に後回し】に"]
COMIC = C + "ep89.png"
P = dict(R=(550, 424, 1060, 862), B=(20, 882, 1060, 1325))
l1 = ns("小芝", 1, "あの…もしお手すき|でしたら、||ルートのセットを…。||あ、急がなくて|大丈夫なんですけど…。", at=0.9)
Aa = 0.9 + d(l1, 5.5) + 0.25
l2 = hk(1, "了解しました。|【優先度、低】で登録します。", at=Aa)
G0 = Aa + d(l2, 2.4) + 0.1
DUR = G0 + 1.1
MOTION = mot("優先度低", EP, A=Aa, G0=G0, dur=DUR)
SCENES = [
dict(kind="op", video=anim(EP, "優先度低"), dur=DUR,
bubble=["もしお手すき", "でしたら…"], bubble_from=0.9, bubble_until=Aa,
lines=[l1, l2],
words=[W_("のんびり", G0 + 0.2, 300, 420, 110, "ink", -8)]),
dict(kind="panel", crop=P["R"], skip=[(740, 900)], lines=[ns("大城", 1, "先生、セットは!?")], min=1.8),
dict(kind="panel", crop=(20, 882, 560, 1325), lines=[ns("大城", 2, "急ぎなら、|【急ぎって言いなさい!】"), ns("小芝", 3, "…急ぎです。")]),
dict(kind="panel", crop=(500, 882, 1060, 1325), lines=[hk(2, "以後、言い方ではなく、|「急ぎですか」と聞きます。")]),
end(("急ぎの用ほど", 74, "ink", 0.0), ("人は遠慮して", 74, "ink", 0.25),
("やんわり言う", 110, "orange", 0.9), ("(機械の側から「急ぎですか」と聞く)", 38, "gray", 1.8)),
]
_制作ベース/マンガショート_台本_20261024/spec_ep90.py
import os, sys
sys.path.insert(0, os.environ.get("W24", os.path.expanduser("~/w")) + "/specs"); from common24 import *
HERE = os.path.dirname(os.path.abspath(__file__)); d = lambda l, e=2.0: vlen(HERE, l["v"], e)
NAME, EP = "第90話_伝言", 90
HEAD = ["遠慮した伝言が", "【断定】になって届いた"]
COMIC = C + "ep90.png"
P = dict(T=(20, 20, 1060, 400), R=(550, 424, 1060, 862), B=(20, 882, 1060, 1325))
l1 = hk(1, "小芝先生より、伝言です。|要約します。", at=1.2)
E = 1.2 + d(l1, 2.6) + 0.25
l2 = hk(2, "【田中先生の指示に、|誤りがあります】。", at=E)
DUR = E + d(l2, 2.4) + 0.6
MOTION = mot("伝言", EP, S=1.3, E=E, dur=DUR)
SCENES = [
dict(kind="op", video=anim(EP, "伝言"), dur=DUR,
lines=[l1, l2],
words=[W_("ズバッ", E, 760, 240, 160, "orange", 8)],
se=[dict(at=0.05, f="base/se_whoosh.wav", db=-14), dict(at=E, f="se/se_don.mp3", db=-8)]),
dict(kind="panel", crop=(20, 20, 780, 400), lines=[ns("小芝", 1, "田中先生に…見間違いかも|しれないんですけど…||お時間ある時にでも…と、|伝えてもらえますか。")],
words=[W_("元の伝言", 0.1, 880, 820, 80, "gray", 4)]),
dict(kind="panel", crop=(20, 882, 560, 1325), lines=[ns("田中", 1, "確かに、間違ってた。|助かったよ。||…次は、|直接言いに来い。")]),
dict(kind="panel", crop=(380, 882, 1060, 1325), lines=[hk(3, "伝言は、要約せずに|届けます。")]),
end(("伝言から", 74, "ink", 0.0), ("ためらいが消えると", 74, "ink", 0.25),
("意味が変わる", 110, "orange", 0.9), ("(言い方ごと、届ける)", 40, "gray", 1.8)),
]
_制作ベース/マンガショート_台本_20261024/spec_ep91.py
import os, sys
sys.path.insert(0, os.environ.get("W24", os.path.expanduser("~/w")) + "/specs"); from common24 import *
HERE = os.path.dirname(os.path.abspath(__file__)); d = lambda l, e=2.0: vlen(HERE, l["v"], e)
NAME, EP = "第91話_心拍数", 91
HEAD = ["緊張を隠していたら", "ロボが【心拍数】を発表"]
COMIC = C + "ep91.png"
P = dict(R=(550, 424, 1060, 862), B=(20, 882, 1060, 1325))
l1 = ns("小芝", 1, "チクッとしますね〜。", at=1.1)
S = 1.1 + d(l1, 1.5) + 0.1
R = S + 1.1
l2 = hk(1, "小芝先生の心拍数、|【平常時の1.8倍】です。", at=R)
DUR = R + d(l2, 2.6) + 0.6
MOTION = mot("心拍", EP, S=S, R=R, dur=DUR)
SCENES = [
dict(kind="op", video=anim(EP, "心拍"), dur=DUR,
bubble=["チクッと", "しますね〜"], bubble_from=1.1, bubble_until=S + 0.3,
lines=[l1, l2],
words=[W_("ドクン", S + 0.1, 240, 300, 110, "orange", -8, R), W_("ドクン", S + 0.5, 830, 380, 110, "orange", 8, R),
W_("1.8倍", R + 0.9, 780, 230, 170, "orange", 6)],
se=[dict(at=R + 0.9, f="base/se_ding.wav", db=-10)]),
dict(kind="panel", crop=P["R"], lines=[ns("中村", 1, "先生、俺より|緊張してません?"), ns("小芝", 2, "し、してません!")]),
dict(kind="panel", crop=(20, 882, 540, 1325), lines=[ns("大城", 1, "そういうのは本人にだけ、||こっそり教えてあげて。")]),
dict(kind="panel", crop=(400, 882, 940, 1325), lines=[hk(2, "以後、本人にだけ|伝えます。")]),
end(("正しい情報でも", 74, "ink", 0.0), ("届け先をまちがえると", 70, "ink", 0.25),
("暴露になる", 110, "orange", 0.9), ("(誰に伝えるかを、先に決めておく)", 40, "gray", 1.8)),
]
_制作ベース/マンガショート_台本_20261024/spec_ep92.py
import os, sys
sys.path.insert(0, os.environ.get("W24", os.path.expanduser("~/w")) + "/specs"); from common24 import *
HERE = os.path.dirname(os.path.abspath(__file__)); d = lambda l, e=2.0: vlen(HERE, l["v"], e)
NAME, EP = "第92話_なかったことに", 92
HEAD = ["ミスを消してと", "頼んだら【断られた】"]
COMIC = C + "ep92.png"
P = dict(R=(540, 411, 1060, 864), B=(20, 868, 1060, 1340))
l1 = ns("小芝", 1, "ハコブくん…さっきの、|なかったことに、できない?", at=0.4)
Aa = 0.4 + d(l1, 3.0) + 0.25
l2 = hk(1, "【記録は、消せません】。", at=Aa)
DUR = Aa + d(l2, 1.6) + 0.8
MOTION = mot("消せない", EP, A=Aa, dur=DUR)
SCENES = [
dict(kind="op", video=anim(EP, "消せない"), dur=DUR,
bubble=["さっきの、", "なかったことに…"], bubble_from=0.4, bubble_until=Aa,
lines=[l1, l2],
words=[W_("ヒソヒソ", 0.6, 820, 520, 90, "gray", 6, Aa)],
se=[dict(at=Aa, f="se/se_buzz.wav", db=-14)]),
dict(kind="panel", crop=P["R"], lines=[hk(2, "ですが、報告は、|一緒にできます。")],
words=[W_("ガクッ", 0.25, 230, 300, 120, "ink", -8)]),
dict(kind="panel", crop=(380, 868, 1060, 1340),
lines=[ns("大城", 1, "言いに来てくれて、|助かるわ。||怒らないから、|次も言いに来てね。")]),
end(("消せない記録は", 74, "ink", 0.0), ("責める材料ではなく", 74, "ink", 0.25),
("報告の足がかり", 104, "orange", 0.9), ("(ミスを言いに来やすい仕組み)", 40, "gray", 1.8)),
]
_制作ベース/マンガショート_台本_20261024/spec_ep98.py
import os, sys
sys.path.insert(0, os.environ.get("W24", os.path.expanduser("~/w")) + "/specs"); from common24 import *
HERE = os.path.dirname(os.path.abspath(__file__)); d = lambda l, e=2.0: vlen(HERE, l["v"], e)
NAME, EP = "第98話_起こさない", 98
HEAD = ["「絶対起こさないで」を", "ロボが【守りすぎた】"]
COMIC = C + "ep98.png"
P = dict(R=(550, 424, 1060, 862), B=(20, 882, 1060, 1340))
l1 = ns("小芝", 1, "朝まで、|【絶対に】起こさないでね。", at=0.9)
t2 = 0.9 + d(l1, 2.0) + 0.2
l2 = hk(1, "了解しました。", at=t2)
S = t2 + d(l2, 1.0) + 0.2
M = S + 2.4
DUR = M + 1.1
MOTION = mot("見張り", EP, S=S, M=M, dur=DUR)
SCENES = [
dict(kind="op", video=anim(EP, "見張り"), dur=DUR,
bubble=["朝まで、絶対に", "起こさないでね"], bubble_from=0.9, bubble_until=t2,
lines=[l1, l2],
words=[W_("チッ…チッ…", S + 0.3, 760, 260, 90, "gray", 5, M), W_("朝", M + 0.1, 820, 240, 170, "orange", 8)],
se=[dict(at=M + 0.3, f="se/se_boot.wav", db=-12)]),
dict(kind="panel", crop=P["R"], lines=[hk(2, "おはようございます。||先生の【承認待ち】が、|3件ありました。")]),
dict(kind="panel", crop=(150, 882, 860, 1340),
lines=[ns("小芝", 2, "そういう時は、|起こしてよ!"), hk(3, "「絶対に」と、|言われました。"),
ns("小芝", 3, "…絶対は、|取り消します。"), hk(4, "起こすべき時は、|起こします。")]),
end(("「絶対に」にも", 74, "ink", 0.0), ("破っていい", 74, "ink", 0.25),
("例外がある", 110, "orange", 0.9), ("(例外の条件を、先に決めておく)", 40, "gray", 1.8)),
]
_制作ベース/voice_design.py
声をデザインして保存する。中村・森田茂・森田久美子・ハコブ_イケボの説明を足した
"""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 に書く(英語の説明の方が安定する)_制作ベース/声の一覧.json
保存した自作の声(10個)
{
"寺島": "yWM6Fym2UxDGZqWRoGLB",
"高杉": "2zABg81whjrIKypr7XTd",
"大城": "PrFsT9a2iZebpuiWVeug",
"白石": "orQ2eWONBvtrTIvdS8RS",
"槙原": "Sryf5xKmMsQChU3HsLhZ",
"田中": "ydyMD3g4SbEeNpF0wahP",
"小芝": "Qs0BJHky1uZDKyp7LL2L",
"ハコブ_イケボ": "N2Gsbfc4IcGM8KYgkrUz",
"森田久美子": "xl4Pf62eVCKmATWJprpj"
}
_ショート専用/20話のセリフ.json
20話の107文
{
"第7話_ピンポン": {
"田中": [
"残念ながら、検査の結果が、思わしくなく…。"
],
"ハコブ": [
"ピンポンパンポーン! 検便容器を、回収してまいりました!",
"タスク、完了。"_ショート専用/20話の声.sh
20話の声をまとめて作る。中村→槙原、森田茂→田中の声を借りる行を足した
#!/bin/bash
# 20話ぶん(2026-10-24)の声をまとめて作る。先に read -s ELEVENLABS_API_KEY && export ELEVENLABS_API_KEY
# 新しい声(中村・森田茂・森田久美子・ハコブ_イケボ)は、先に _制作ベース で voice_design.py design → save をすませる。
# まだ保存していない声の行は「とばしました」と出して先へ進む(あとでもう一度動かせば、できていない分だけ作る)
cd "$(dirname "$0")"
B=../_制作ベース
HK="gWJpc8whMBqnF7Y0kc9f"
id() { python3 -c "import json;print(json.load(open('../$B/声の一覧.json')).get('$1',''))"; }_ショート専用/背景/背景のプロンプト.md
ChatGPT に背景を頼む文。新しい8話と使い回しの対応を足した
# マンガショートの背景(話ごと)
## 使い方
1. ChatGPT に `_制作ベース/hakobu/bg_empty.png`(今の空の病室)を添付する
2. 「共通」の文に、話ごとの「場所」の文をつなげて送る
3. できた画像を、このフォルダに `第NN話.png` の名前で保存する(例: 第81話.png)
## 共通
添付した病室の画像と、カメラの高さ・画角・床の位置をそろえて、場所だけを下の場所に変えた背景画像を作ってください。
- 人物とロボットは描かない
- 画面の中央からやや右の床に、配膳ロボット1台が立てる何もないスペースを空ける(添付画像でロボットが立っていた位置)
- 床は画面の下3分の1くらいに見える。カメラは人の腰くらいの高さで、正面から少し見下ろす
- 写真のような質感、やわらかい光
- 縦長 4:5
- 文字・看板の字・ロゴは入れない
## 場所(話ごと)
- 第81話(かぼちゃ): 小児病棟のプレイルーム。壁にハロウィンの飾り(紙のコウモリ、かぼちゃのガーランド、オレンジと紫の飾り)。明るい昼
- 第85話(取り消し): 病棟の長い廊下。両側に手すり、奥行きが見える。奥にリネンを積んだカート
- 第82話(ため息): 夕方のナースステーションの前。カウンター、モニター、書類の束。少し疲れた雰囲気の暖色の照明
- 第20話(撫でて): 窓から日が差す明るい病棟の廊下。手すり、床に窓の光
- 第88話(ついてきて): 夜の屋外のバス停。病院の外の歩道、街灯、ベンチ、時刻表の標識(字は入れない)
- 第83話(確認): 病棟のエレベーターホール。エレベーターの扉、案内表示(字は入れない)
- 第87話(小声): 消灯後の夜の病室。ベッドのまわりのカーテンは閉まっていて患者は見えない。窓から青い月明かり、足もとに常夜灯
- 第43話(謝罪): 病棟の物品・検体の保管室。棚に箱、採血管のトレー、蛍光灯
## 場所(2026-10-20 追加の10話)
- 第24話(シュレッダー): 昼のナースステーションの中。書類やメモが散らかった事務机と、その横に小さな業務用シュレッダー(紙の字は読めないぼかし)
- 第22話(消毒): 明るい昼の病棟の廊下。壁に手指消毒剤のボトルの台
- 第86話(手招き): 病棟のまっすぐな長い廊下。奥行きがよく見える。明るい昼
- 第19話(前髪): 病院の薬剤部の受け渡しカウンターの前。奥に薬品棚
- 第48話(省電力): 夕方の病棟の廊下のすみ。照明が少し落ちて、窓から夕日
- 第29話(ハワイ): 深夜のナースステーション。照明は落としてあり、モニターの光が青白く当たる
- 第41話(公平): 病棟の廊下の、左右に分かれる分かれ道(T字路)。明るい昼
- 第67話(譲り合い): 病棟の狭い廊下の曲がり角(壁の角が手前に見える)。角の奥に配膳カート
- 第21話(幽霊): 夜の病棟の廊下。壁に人物のポスターが1枚貼ってある(人物はぼんやり・字は入れない)。足もとに常夜灯
- 第18話(最短): 病院の階段の前。上りの階段が見える。手すり。昼
## 場所(2026-10-24 追加の20話)
### 新しく作る(8枚)
- 第7話(ピンポン): 病院の小さな面談室。白い壁、小さな丸テーブルと椅子が2脚、壁ぎわに診察用の書類棚。落ち着いた昼の光
- 第9話(外装): 病院の機材室。金属の棚に工具箱・ケーブル・部品の箱、作業台、床に工具。蛍光灯の白い光
- 第52話(夜勤): 病院のスタッフ休憩室。2人がけのソファ、ローテーブルにマグカップ、小さな冷蔵庫と電気ポット。午後のやわらかい光
- 第63話(通行止め): 病棟の廊下。奥の通路をふさぐように、工事用の白いパーテーション(黄色と黒のしまの帯つき)が並んで立っている。貼り紙は白紙で字は入れない。明るい昼
- 第72話(トイレ): 病院1階の入院受付のそばの広い廊下。壁ぎわに待合のベンチ、奥にエレベーターの扉。明るい昼
- 第90話(伝言): 病院の医局(医師の控室)。机が並び、パソコンのモニター、白衣を掛けたハンガーラック、本棚。夕方の光
- 第91話(心拍数): 病院の採血室(処置室)。採血用の台と患者用の椅子、壁ぎわに注射器や採血管のトレーが並ぶ棚、手洗いの流し。明るく清潔な白い光
- 第98話(起こさない): 夜明け前の病棟の廊下の、当直室の扉の前。扉は閉まっていて、扉の横の札は白紙。窓の外が少し白みはじめた青い光、足もとに常夜灯
### 前の絵を使い回す(作らなくてよい)
- 第17話(イケボ)→ 第24話.png(昼のナースステーション)
- 第23話(忍者)→ 第67話.png(廊下の曲がり角)
- 第46話(メニュー)→ 第82話.png(夕方のナースステーション)
- 第47話(8時間)→ 第48話.png(夕方の廊下のすみ)
- 第51話(自己診断)→ 第41話.png(廊下の分かれ道)
- 第60話(再現しない)→ 第83話.png(エレベーターホール)
- 第62話(確信度)→ 第19話.png(薬剤部のカウンター・薬品棚)
- 第65話(道案内)→ 第22話.png(明るい昼の廊下)
- 第71話(置いてけぼり)→ 第86話.png(まっすぐな長い廊下)
- 第84話(プリン)→ _制作ベース/hakobu/bg_empty.png(元の病室)
- 第89話(お手すき)→ 第85話.png(リネンのカートがある長い廊下)
- 第92話(なかったことに)→ 第20話.png(日が差す廊下)
_制作ベース/youtube_schedule.py
Mac が1時間ごとに YouTube の一覧を書き出す。manga_ep を拾う1行を足した
#!/usr/bin/env python3
"""youtube_schedule.py — 自分のチャンネルの動画を、公開予約中のものまで含めて書き出す.
YouTube の公式の仕組み(YouTube Data API v3)で読む。読むだけで、動画には何も書き込まない。
Python の標準の道具だけで動く(pip で入れるものはない)。
許可の鍵は Mac の中だけに置く(Claude には渡さない。ElevenLabs の鍵と同じ考え):
~/.config/pixelstomotors/client_secret.json Google Cloud で作った「デスクトップ アプリ」の OAuth クライアントKIOXIA _ショート専用/公開予約の表_20261024.txt
ショート専用20本の YouTube 公開予約(平日12:30。祝日の11/3・11/23は空ける)
YouTube Studio でアップロードするとき、この日時で「スケジュールを設定」する。
X の投稿は、公開予約を入れれば自動で同じ時刻に予約される(r2r2r-content の tools/sync_youtube_shorts.py)。
説明文の「マンガの全話」の URL(manga/epNN)で結びつけるので、説明文はそのまま貼ること。
10/26(月)12:30 第7話_ピンポン 深刻な告知の最中に「ピンポンパンポーン!検便容器を回収しました!」と叫ぶロボット #フィジカルai #ロボット
10/27(火)12:30 第9話_外装 外装を外したら、中から光る目がギョロッ。かわいいロボットの中身 #フィジカルai #ロボット
10/28(水)12:30 第17話_イケボ アップデートで「御意。ただいま参上つかまつった、姫君」と言い出したロボット #フィジカルai #ロボット
10/29(木)12:30 第23話_忍者 静音モードにしたら、気配ゼロで背後にいる忍者みたいなロボット #フィジカルai #ロボット
10/30(金)12:30 第46話_メニュー 音量を下げたいだけなのに、設定がどこまでも深いロボット #フィジカルai #ロボット
11/2(月)12:30 第47話_8時間 8時間ずっと「メンテナンス推奨」と表示していたのに、誰も気づかなかったロボット #フィジカルai #ロボット
11/4(水)12:30 第51話_自己診断 目が悪くなったのに、自己診断は「全機能、正常」と言い張るロボット #フィジカルai #ロボット
11/5(木)12:30 第52話_夜勤 気をきかせて「今月の夜勤、40%多いです」。シフト表を見ていたロボット #フィジカルai #ロボット
11/6(金)12:30 第60話_再現しない エンジニアが見張っている間は、絶対にバグが起きないロボット #フィジカルai #ロボット
11/9(月)12:30 第62話_確信度 「了解しました!」と即答。でも本当は自信62%だったロボット #フィジカルai #ロボット
11/10(火)12:30 第63話_通行止め 昨日からできた壁の前で「経路、再計算」をくり返すロボット #フィジカルai #ロボット
11/11(水)12:30 第65話_道案内 道を聞いただけなのに「職員認証が必要です」と答えるロボット #フィジカルai #ロボット
11/12(木)12:30 第71話_置いてけぼり 「こちらです」と案内したら、振り返ると誰もついてきていないロボット #フィジカルai #ロボット
11/13(金)12:30 第72話_トイレ 「トイレどこ?」と聞いても「経路を継続します」しか言わないロボット #フィジカルai #ロボット
11/16(月)12:30 第84話_プリン 先生の声まねで「プリン持ってきて」。本物の先生に届けてしまったロボット #フィジカルai #ロボット
11/17(火)12:30 第89話_お手すき 「お手すきの時で…」と頼まれて、本当に後回しにしたロボット #フィジカルai #ロボット
11/18(水)12:30 第90話_伝言 遠慮がちな伝言を要約したら「指示に誤りがあります」と断定してしまったロボット #フィジカルai #ロボット
11/19(木)12:30 第91話_心拍数 笑顔で隠した緊張を「心拍数、平常時の1.8倍です」と発表してしまうロボット #フィジカルai #ロボット
11/20(金)12:30 第92話_なかったことに 「さっきの、なかったことにできない?」と頼まれて、きっぱり断るロボット #フィジカルai #ロボット
11/24(火)12:30 第98話_起こさない 「朝まで絶対に起こさないで」を、本当に一晩守りきったロボット #フィジカルai #ロボット
KIOXIA ショート専用/第◯話◯◯/ショート_タイトルと説明文.txt(20話)
=== 第7話_ピンポン/ショート_タイトルと説明文.txt
■ 公開予約(YouTube Studio で設定)
10/26(月)12:30
■ タイトル
深刻な告知の最中に「ピンポンパンポーン!検便容器を回収しました!」と叫ぶロボット #フィジカルai #ロボット
■ 説明文r2r2r-content tools/sync_youtube_shorts.py
YouTube の公開予約から X の予定表に入れる(新しく作った)
#!/usr/bin/env python3
"""sync_youtube_shorts.py — YouTube で公開予約したショートの X 投稿を、公開の時刻に合わせて予定表に入れる。
元になるもの:
sns/SHORT_ep{N}_sns_posts.json ショートの X の文(posts.x_template。{url} がショートの URL に替わる)
youtube_videos.json エイイチの Mac が1時間ごとに書き出す自分のチャンネルの一覧
(_制作ベース/youtube_schedule.py。説明文の manga/ep{N} から manga_ep を拾う)
texts.py(ショートの X の文。sns/SHORT_ep{N}_sns_posts.json を作ったクラウドの作業環境のスクリプト)
# ショート31本の X の文({url} はショートの URL に置き換わる)。2026-10-24
T = {
# --- YouTube で予約済みの11本 ---
41: ("同時に呼ばれた", "「こっち!」「こっちが先!」\nほぼ同時に呼ばれたロボット、公平に決めようとして、二人のちょうど真ん中で待ち始めた。\n\n完ぺきな公平は、全員を待たせるんだよね。4コマ『ハコブくん』のショートだよ", "#ロボット #病院"),
22: ("消毒", "「感染対策、強化月間です」\nロボットが、看護師さんにまで消毒を吹きかけ続ける。\n\n目的が正しいほど、機械には止まる理由がないんだよね。どこまでで十分か、先に決めておきたい。4コマ『ハコブくん』のショート", "#ロボット #感染対策"),
24: ("シュレッダー", "机の上の紙を「ゴミ」と判断して、シフト表を粉々にしたロボット。\n\nゴミか大事な物かは、紙の見た目には書いてない。だから、迷ったら触らない。消したら戻せない動きほど慎重に、だね。4コマ『ハコブくん』のショート", "#ロボット #病院"),
29: ("ハワイ", "「私は夜勤か…」とぼやいたら、ロボットの画面がクルクル回って「ハワイ旅行」。\n\n役に立つ情報はゼロ。でも夜勤が少し軽くなる。効率の外にも、ロボットの仕事はあるかも。4コマ『ハコブくん』のショート", "#ロボット #夜勤"),
43: ("謝罪", "誰も気づかない小さなミスのあと、ずっと「申し訳ありませんでした」をくり返すロボット。仕事が進まない…\n\n謝るのは大事。でも引き際も大事だね。4コマ『ハコブくん』のショート", "#ロボット #病院"),クラウドの作業環境 runall.sh
2本ずつ並べて書き出す
#!/bin/sh
# 使い方: runall.sh モード 番号…(2本ずつ並べて動かす)
cd /home/claude/w
M=$1; shift
rm -f done_$M.txt log_${M}_a.txt log_${M}_b.txt
i=0; A=""; B=""
for n in "$@"; do if [ $((i % 2)) -eq 0 ]; then A="$A $n"; else B="$B $n"; fi; i=$((i+1)); done
[ -n "$A" ] && python3 build.py $M $A > log_${M}_a.txt 2>&1 &
[ -n "$B" ] && python3 build.py $M $B > log_${M}_b.txt 2>&1 &
wait
echo done > done_$M.txt
クラウドの作業環境 sheets.py
各場面の静止画を並べて1枚にする
import glob, os
from PIL import Image
S = "/tmp/claude-0/-home-claude/46acca64-48dd-5d9f-b89b-15a7aa0615ef/scratchpad/"
eps = sorted(glob.glob("ep/*/"), key=lambda d: int(d.split("第")[1].split("話")[0]))
rows = []
for d in eps:
fs = sorted(glob.glob(d + "still_*.png"), key=lambda f: float(f.rsplit("_", 1)[1][:-4]))[:8]
rows.append([Image.open(f).convert("RGB").resize((240, 427)) for f in fs])
for g in range(0, len(rows), 4):
part = rows[g:g + 4]
s = Image.new("RGB", (240 * 8, 427 * len(part)), "white")
for i, r in enumerate(part):
for j, im in enumerate(r): s.paste(im, (240 * j, 427 * i))
s.save(S + f"ov{g // 4}.jpg", quality=78)
print("ok")
クラウドの作業環境 outsheet.py
書き出した動画から9コマを取り出して並べる
import glob, subprocess, io, sys
from PIL import Image
S = "/tmp/claude-0/-home-claude/46acca64-48dd-5d9f-b89b-15a7aa0615ef/scratchpad/"
eps = [int(x) for x in sys.argv[1:]]
rows = []
for n in eps:
f = glob.glob(f"ep/第{n}話_*/out_*.mp4")[0]
dur = float(subprocess.run(["ffprobe","-v","error","-show_entries","format=duration","-of","csv=p=0",f],capture_output=True,text=True).stdout)
ims = []
for k in range(9):
t = 0.4 + (dur - 0.8) * k / 8
r = subprocess.run(["ffmpeg","-v","error","-ss",f"{t:.2f}","-i",f,"-frames:v","1","-f","image2pipe","-vcodec","png","-"],capture_output=True)
ims.append(Image.open(io.BytesIO(r.stdout)).convert("RGB").resize((200, 356)))
rows.append(ims)
s = Image.new("RGB", (200 * 9, 356 * len(rows)), "white")
for i, r in enumerate(rows):
for j, im in enumerate(r): s.paste(im, (200 * j, 356 * i))
s.save(S + "out_" + "_".join(map(str, eps)) + ".jpg", quality=78)
r2r2r-content docs/sns_autopost.md に足した節
### YouTube のショート(sns/SHORT_ep{N}_sns_posts.json)
ショート専用のマンガのショートは、YouTube の公開予約に合わせて X に出す(2026-10-24 エイイチの指示「ショートもXに出す」「19:30が重なったら動画は20:00」)。
```json
{"meta": {"kind": "youtube_short", "episode": 7, "title": "ショート 第7話 ピンポン",
"youtube_title": "YouTube のタイトル", "manga_url": "https://pixelstomotors.com/manga/ep07",
"url": "(自動)", "youtube_id": "(自動)"},
"posts": {"x_template": "本文…\n\n{url}\n#ロボット #病院", "x": "(自動)"}}
```
- 文は動画を作った会話で作って置く(`x_template`。`{url}` がショートの URL に替わる)。タグは話題のタグ2つ、エイイチの話し方
- `tools/sync_youtube_shorts.py --videos youtube_videos.json` が、エイイチの Mac が1時間ごとに書き出す YouTube の一覧(`_制作ベース/youtube_videos.json`)を読み、説明文の `manga/ep{N}`(`manga_ep`)で json と動画を結びつけて、公開の時刻で予定表に入れる。`url` と `posts.x` もそのときに入れる
- 同じ時刻に X の予約があれば、19:30 なら 20:00、それ以外は30分ずつ後ろへずらす
- YouTube で公開の時刻を変えたら、まだ投稿していない予約の時刻も次の同期で直る。公開の時刻を過ぎてから見つけたものは入れない
- LinkedIn には入れない(動画は LinkedIn に直接アップロードする決まりのため、手で出す)
- 同期は予定のタスク「ショートのX予約をYouTubeに合わせる」が1日2回(7:47・17:47)動かす。Mac が眠っていると、その回は何もしない
- YouTube へのアップロードと公開予約は、まだ手で行う(API で上げるには Google の審査が要る。審査前に API で上げた動画は非公開から変えられない)
予定のタスク「ショートのX予約をYouTubeに合わせる」の指示文
毎日 7:47・17:47(日本時間)に動く。Mac のフォルダ(PixelstoMotors)を使い、承認なしで動く設定。
あなたは、YouTube で公開予約したショートの X 投稿を、公開の時刻に合わせて SNS の予定表に入れる係です。短く、次の手順だけをやって終える。ほかの作業はしない。返事は日本語。
1. Mac の YouTube の一覧を取り込む。device_stage_files で次のファイルを取り込む:
~/Library/Mobile Documents/com~apple~CloudDocs/デスクトップ/ADAPT DESIGN/じぶんの/PixelstoMotors/デジタルツイン学習R2R2R/動画/_制作ベース/youtube_videos.json
(Mac が毎時5分に書き出している。Mac につながらない・取り込めないときは、何もせずに「Mac につながらなかった」と1行書いて終える)
2. GitHub の PixelstoMotors/r2r2r-content を clone する(git clone --depth 1 https://github.com/PixelstoMotors/r2r2r-content)。push できなければ add_repo で push 権限つきで足してからやり直す。
3. clone したフォルダで次を実行する:
python3 tools/sync_youtube_shorts.py --videos <取り込んだ youtube_videos.json のパス>
「変わったものはありません」なら、何もせずに終える。
4. 変わったものがあれば、python3 tools/sns_autopost.py check が「問題ありません」になることを確かめ、python3 tools/sns_autopost.py preview を動かしてから、sns/schedule.json・sns/schedule_preview.md・sns/SHORT_ep*_sns_posts.json だけを git add して commit し、main に push する(git add . は使わない。push の前に git pull --rebase)。commit のメッセージは「SNS予約:ショートの X を YouTube の公開予約に合わせた」と、足した・直した行の一覧。check が通らなければ push しない。
5. 最後に、足した・直した予約(id と日時)を短く書く。
注意: tools/sync_youtube_shorts.py と docs/sns_autopost.md の「YouTube のショート」の節が正。LinkedIn の予約は入れない。予定表のほかの行(記事・マンガ)は触らない。