---
title: "4コママンガ『ハコブくん』のコマに動きを足した ― 次は描く前に構図を決める"
slug: hakobu-4koma-dynamic-panels
date: 2026-11-05
day: 447
meta_description: "Gemini の画像生成で描く4コママンガ『ハコブくん』は、コマごとに寄り・引き・カメラの高さを変えて描かせると単調さが消えた。第134〜137話を日本語・英語・ショート4本まで1日で出した。"
tags: [4コママンガ, ハコブくん, 縦長マンガ, 画像生成AI, Gemini, カメラワーク, YouTubeショート, AI活用]
ai_consult: true
series: "ひとり研究所の裏側"
cover: "/media/LOG447_cover.jpg"
---

**Gemini の画像生成 API で描く4コママンガ『ハコブくん』は、コマごとに寄り・引き・カメラの高さ・向きを変えるよう指示すると、同じ大きさの人とロボットが4コマ並ぶ単調さが消えた。** この形で第134〜137話の4話を描き、pixelstomotors.com の日本語版と英語版、YouTube ショート4本、SNS の予約までを1日で出しました。

この作業で変わったことは3つです。

- 読む人にとって：1話の中に、顔の大写し、床すれすれから見上げる絵、人の肩越しに奥を見る絵が入り、めくるたびに見え方が変わる
- 作る側にとって：これまで描くたびに形が変わっていた、後ろ向きのハコブくんと、何コマにも出る配膳車を、参照の絵を添えて固定した。次の話から、同じ直しを繰り返さずに済む見込み
- 費用：4話で Gemini に描かせた絵は156枚、1枚約10円で約1,500円。第134話は組み直しで41枚になり、ふだんの1話（20枚前後）の約2倍かかった。作業時間は、前を測っていないので比べられない

『ハコブくん』は、病院で薬や物を運ぶロボット・ハコブくんと、看護師さんたちの4コママンガです。ロボットが現場に入ったときに起きるすれ違いを、実際の病院の調査をもとに毎話1つ描いています。前の記録（LOG413）で、1コマを縦長（縦横比 4:5）の1枚ずつで描き、横にめくって読む形にしました。絵は [Google の Gemini の画像生成 API](https://ai.google.dev/gemini-api/docs/image-generation)（文章と参考の絵を渡すと絵を返す仕組み。使っているのは gemini-3.1-flash-image）で描いています（LOG410）。

困っていたのは、絵がきれいでも、4コマがどれも同じに見えることでした。人もロボットも中くらいの大きさで、目の高さから横に並べて撮ったような絵が4つ続く。マンガで言うカメラワーク（コマごとに、どこから・どれくらい寄って・どの高さから見せるか）がないのです。AI にコマの中身だけを書いて渡すと、いちばん無難な構図で返ってきます。

【画像】LOG447_01_ep134_camerawork.jpg 第134話の1回目と組み直し後の4コマ。1回目は4コマとも同じ引きで人とロボットが横に並ぶ。組み直し後はロボットを手前に大きく置き、肩越し・見上げる寄り・集中線が入っている

そこで、コマごとにショットの大きさ（顔だけか、全身か）、カメラの位置、カメラの高さを、数字で書いて渡すようにしました。高さは、人の目の高さ120〜150cm だけでなく、ハコブくんの目の高さ90cm、床すれすれの30〜60cm も使います。

やったことの全体は次のとおりです。

| やったこと | なぜ | 結果 |
|---|---|---|
| 第134話を試しに描き、カメラワークを組み直した | 4コマが同じ大きさ・同じ高さで単調だった | 寄り・引き・高さ・向きをコマごとに変え、効果線を入れた |
| 後ろ向きのハコブくんの参照の絵と、背中の形の説明を足した | 背中が描くたびに違う形だった | 背中が三面図どおりになった |
| 配膳車の三面図を作り、毎コマ添えた（第135話） | 配膳車の形がコマごとに変わった | 4コマとも同じ形の配膳車になった |
| 床のレーンの線をプログラムで引き直した（第136話） | 線の色・本数・幅がコマごとにずれた | 4本の黒い線と広めの間で、2コマ目と3コマ目がそろった |
| 3コマ目のセリフを変えた（第136話） | 話の流れと絵が合っていなかった | 正面で鉢合わせて、だから、どっちに来るのよ！ にした |
| 4話をサイトに載せ、英語版と表紙を作った | 読める場所を増やす | 日本語4話・英語4話を公開した |
| YouTube ショートを4本作った | マンガを動画でも届ける | 冒頭の動きを話ごとに作り、音が出ない不具合を直した |
| SNS を予約した | 新しい話を知らせる | 日本語は 11/28〜12/1、英語は 1/27〜1/29 |
| チェック項目に、コマの動きの節を足した | 同じ指摘を繰り返さない | 4コマを並べて見る5つの確かめを足した |

ここから下は作業ログです。使ったコード・決まり・出力は、あとで同じ手順を追えるように全文を載せています。効果の欄は、数字があるものは測った値、ないものは見込みです。


## 作業ログ ― 第134話を描き、4コマのカメラワークを組み直した

### プロットと、1回目の第134話

話のもとは、Google スプレッドシートの『ハコブくん』のプロットの表です。第134〜183話の50話が、未着手のまま入っていました。まず第134話だけを試しに描きました。話は、細い廊下で、急ぎの点滴を持った高杉さんの前を、ハコブくんがゆっくり進み、抜こうとするとハコブくんも同じほうへ寄ってしまう、というもの。もとにしたのは、[オールボー大学の病院のロボットの調査](https://www.en.plan.aau.dk/robots-in-hospitals-may-create-more-work-for-staff-n109619)（ゆっくり動くロボットを追い越す余地がなく、職員の仕事が遅れる）です。

1回目の4コマは、上の画像の左のとおりです。絵の間違いはほとんどありませんでしたが、spec のカメラの指示は、4コマとも全身が入る引き（3〜4m 離れて、人とロボットを同じ距離に並べる）で、高さは 120・150・120・120cm でした。どのコマも、同じ大きさの人とロボットが横に並ぶ絵になります。

### カメラワークの指示：コマごとにショット・位置・高さを書く

描く指示は、話ごとの `spec.py` に書きます。`spec.py` は、コマごとに出てくる人・ロボット・セリフ・構図を並べた Python のファイルで、`vbuild.py`（コマの指示文を組み立てて Gemini に渡す道具）がこれを読みます（LOG410）。今回、各コマに `camera` の項目を足し、次の4つを必ず書くようにしました。

| 項目 | 書く中身 | 第134話の1コマ目の例 |
|---|---|---|
| SHOT SIZE | 何がどれくらいの大きさで写るか | ハコブくんの上半身を手前に大きく、高杉さんは後ろに腰から上 |
| CAMERA POSITION | カメラがどこにあるか | ハコブくんの左前、約1.2m |
| CAMERA HEIGHT | カメラの高さと向き | 60cm、少し見上げる |
| FRAME CROP | どこで切るか | ハコブくんの車輪は下の端で切る |

第134話の4コマは、次のように組み直しました。

| コマ | ショット | カメラの高さ | 効果線 |
|---|---|---|---|
| 1 | ハコブくんを手前に大きく、後ろで詰まる高杉さん | 60cm（低い所から見上げる） | ゆっくり進む小さな波線 |
| 2 | 高杉さんの肩越し（肩なめ：手前の人の肩や頭を大きく入れ、その奥を見せる構図）に、後ろ向きのハコブくん | 150cm（高杉さんの目の高さ） | 横へ動くスピード線 |
| 3 | ハコブくんの頭を手前に、見上げた高杉さんを大きく | 90cm（ハコブくんの目の高さ） | 集中線 |
| 4 | 手前を通り抜ける高杉さん、奥で脇へ寄ったハコブくん | 140cm | なし |

- ねらい：隣り合うコマで、大きさと高さを必ず変え、めくる理由を作る
- 変わったこと：4コマのカメラの高さが 120・150・120・120cm から 60・150・90・140cm に、全コマ全身の引きから、手前に大きく・肩なめ・寄り・手前と奥の重ねになった。寄りのコマ（3コマ目）と肩なめ（2コマ目）が入った
- 見込める効果：スワイプで読むとき、1枚ずつの見え方が違うので最後までめくってもらえる見込み。めくられた割合は、サイトの記録で確かめる

4話それぞれの4コマで、カメラの高さとショットの大きさがどう違うかを、下の図で切り替えて見られます。

【図解】LOG447_camera_heights.html

#### 第134話の spec.py

```python
EP, TITLE = 134, "オ先ニドウゾ"
# v2（2026-11-05）：エイイチ「全体にコマに動きがなく単調」→ カメラワークを組み直した
# P1 低い位置・ハコブくんを手前に大きく／P2 肩なめ・後ろ姿・効果線／P3 ハコブくん目線から見上げる寄り・集中線／P4 手前を通り抜ける高杉さんと奥のハコブくん
IV = ("IV BAG IN HER HAND:", ["Takasugi carries ONE small clear IV drip bag, held by its top hanger in one hand. The bag is plain with NO text, NO label",
                             "she holds nothing else"])
PANELS = [
 # 起：のろのろ進むハコブくんと、その後ろで詰まる高杉さん。ハコブくんを手前に大きく、低い所から
 dict(chars=["高杉", "ハコブ"], mouth="NORMAL", no_yline=True, full=[],
      hscale_text=["Hakobukun is CLOSE to the camera and LARGE: its body fills the lower-left of the picture, from about 4% to 58% of the image width, its top at about 42% and its wheels cut off by the bottom edge",
                   "Takasugi is about 2 m BEHIND it, so she looks smaller: her head at about 24% and her waist at about 62% of the image height; the robot hides her legs"],
      camera=["SHOT SIZE: Hakobukun's upper body in the near foreground, Takasugi from the waist up behind it",
              "CAMERA POSITION: in front of Hakobukun and to its left, about 1.2 m away, three-quarter front view of the robot",
              "CAMERA HEIGHT: 60 cm, looking slightly UP — the robot looms in front and the nurse towers behind it",
              "FRAME CROP: Hakobukun's wheels and lower body are cut by the bottom edge"],
      band="3% and 20%",
      comp=["Takasugi stands behind the robot at about 66% of the image width, overlapping the robot's right edge",
            "the bubble sits top-right: a WIDE, LOW oval about 50% of the image width wide and 13% tall, holding two HORIZONTAL rows of text; its tail points down to Takasugi",
            "Takasugi LEANS forward over the robot's back, one hand braced on her knee, the IV bag raised in the other hand"],
      bubbles=[("Takasugi", "ちょっと急いでるんだけど…", ["ちょっと", "急いでるんだけど…"])],
      extra=[IV, ("HAKOBUKUN ORIENTATION:", ["Hakobukun faces camera-left in three-quarter view: its screen face and its RIGHT side with the ring are visible",
                                              "it creeps forward VERY slowly: only a few short, faint, wavy lines under it — a sluggish feeling, NOT speed"]),
             ("EFFECT:", ["a few thin vertical gloom lines over Takasugi's head show her mounting impatience. NO letters, NO sound effect"])],
      scene="Hakobukun crawls along, perfectly content, eyes forward, open smile. Behind it Takasugi is stuck: shoulders tense, leaning forward over the robot, eyebrows knitted, a strained polite smile, one sweat drop, holding the IV bag up at chest height."),
 # 承：右から抜こうとすると、ハコブくんも右へ。高杉さんの肩なめで、ハコブくんの後ろ姿
 dict(chars=["高杉", "ハコブ"], mouth="NORMAL", no_yline=True, back=True,
      prop=("MEAL CART", ["one hospital meal delivery cart: a tall enclosed box-shaped trolley on small casters, with closed doors and a push handle. NO text, NO logo",
                         "it stands parked at the LEFT, its left half cut off by the left edge, occupying x 0–20% and y 22–80% of the image"]),
      hscale_text=["Hakobukun is about 1.5 m ahead of the camera: its height is about 38% of the image height (top at about 40%, wheels at about 78%) and its width that divided by 1.7"],
      camera=["SHOT SIZE: over-the-shoulder; Takasugi's head, right shoulder and upper back in the near foreground, Hakobukun ahead of her",
              "CAMERA POSITION: right behind Takasugi's right shoulder", "CAMERA HEIGHT: 150 cm (her eye level)",
              "FOREGROUND: Takasugi's back of head (short black bob) and right shoulder, large, cut by the right and bottom edges, occupying x 55–100% and y 30–100%. Her face shows only as a profile edge — one irritated eye and a clenched mouth",
              "NOT a front-facing lineup. NOBODY faces the viewer directly."],
      comp=["Hakobukun is centred at about 48% of the image width, seen from DIRECTLY BEHIND (see the REAR VIEW section)",
            "there is NO speech bubble in this picture"],
      extra=[IV, ("THE MOVE — BOTH SHIFT RIGHT AT ONCE:", [
              "Takasugi leans and steps to HER right (camera-right) to overtake; bold horizontal speed lines stream from her shoulder toward the right",
              "at the same instant Hakobukun slides to the right too, blocking her: a set of bold horizontal speed lines on the LEFT side of the robot and a small after-motion outline of its edge",
              "draw NO arrows, NO letters, NO sound effect"])],
      scene="Takasugi tries to pass on the right — and Hakobukun innocently drifts right as well, exactly into her path. The parked meal cart blocks the left. Comic frustration."),
 # 転：ずっと後ろ。ハコブくん目線から見上げる寄りで、高杉さんの爆発と、すまし顔の画面
 dict(chars=["高杉", "ハコブ"], mouth="NORMAL", no_yline=True,
      hscale_text=["Hakobukun is in the extreme near foreground: only its screen head and the top of its body are visible, filling the bottom of the picture from about 10% to 70% of the image width, its top edge at about 62% of the image height, cut by the bottom edge"],
      camera=["SHOT SIZE: Takasugi from the waist up, LARGE, looming; Hakobukun's screen-head in the near foreground",
              "CAMERA POSITION: low, just beside Hakobukun's front-left corner, looking back and UP at Takasugi who walks right behind it",
              "CAMERA HEIGHT: 90 cm (Hakobukun's eye level), looking UP at her",
              "FRAME CROP: Takasugi's head near the top, her body cut at the waist by Hakobukun's head",
              "NOT a front-facing lineup. NOBODY faces the viewer directly."],
      band="3% and 30%",
      comp=["Takasugi is centred at about 58% of the image width, her face between 30% and 44% of the image height",
            "two bubbles: 「安全運転なのは分かるけど、私が遅れてる！」 large at the upper right, its tail to Takasugi; 「ワタシハ、安全ニ走ッテイマス」 at the lower left above the robot's screen, its tail to the robot"],
      bubbles=[("Takasugi", "安全運転なのは分かるけど、私が遅れてる！", ["安全運転なのは", "分かるけど、", "私が遅れてる！"]),
               ("Hakobukun", "ワタシハ、安全ニ走ッテイマス", ["ワタシハ、", "安全ニ走ッテイマス"])],
      extra=[IV, ("HAKOBUKUN ORIENTATION:", ["the robot's screen-head is seen from its front-left at three-quarter, its calm face clearly visible"]),
             ("EFFECT:", ["bold radiating focus lines (concentration lines) burst out from behind Takasugi's head toward the edges — her frustration explodes",
                          "a cross-shaped anger mark on her temple. NO letters, NO sound effect"])],
      scene="Still stuck behind it all the way down the corridor. Takasugi explodes: mouth wide open, eyebrows sharply angled, free hand flung out, the IV bag swinging. In the foreground Hakobukun keeps its serene open smile, utterly unbothered."),
 # 結：めくった瞬間、手前を高杉さんがスッと抜けていく。奥で印に寄って止まったハコブくんと白石さん
 dict(chars=["白石", "高杉", "ハコブ"], mouth="NORMAL", no_yline=True,
      prop=("FLOOR MARKING", ["one rectangular floor marking made of black-and-white striped tape, lying FLAT on the invisible floor, seen in perspective as a flat parallelogram. Hakobukun stands inside it. NO text, NO arrow",
                              "Shiraishi holds a roll of the same striped tape"]),
      hscale_text=["Takasugi is in the near foreground and LARGE: from the top of her head at about 22% down to her waist, cut by the bottom edge, at the RIGHT, x 52–96%",
                   "Hakobukun and Shiraishi are about 3 m away in the middle distance, at the LEFT: both stand on the same floor, wheels and shoes at about 82% of the image height",
                   "Shiraishi (160 cm) has her head top at about 42%; Hakobukun (115 cm) has its top at about 54% — it reaches just below her chest"],
      camera=["SHOT SIZE: Takasugi waist-up in the foreground; Hakobukun and Shiraishi full body in the distance",
              "CAMERA POSITION: front three-quarter, slightly to the right of the walking line", "CAMERA HEIGHT: 140 cm",
              "NOT a front-facing lineup. NOBODY faces the viewer directly."],
      band="3% and 22%",
      comp=["Hakobukun at about 22% of the image width, inside the floor marking; Shiraishi at about 38%, just beside the marking",
            "two bubbles: 「オ先ニドウゾ」 at the upper left, its tail down to Hakobukun; 「それが一番安全」 at the upper right, its tail down to Takasugi"],
      bubbles=[("Hakobukun", "オ先ニドウゾ", ["オ先ニドウゾ"]), ("Takasugi", "それが一番安全", ["それが", "一番安全"])],
      extra=[IV, ("HAKOBUKUN ORIENTATION:", ["Hakobukun has pulled aside into the box and STOPPED, its body turned toward the walking line, screen visible, three-quarter"]),
             ("EFFECT:", ["light, airy horizontal speed lines trail behind Takasugi to the LEFT — she glides past smoothly. NO letters, NO sound effect"])],
      scene="The fix works. Takasugi strides past in the foreground toward camera-right, relaxed, a refreshed satisfied smile, eyes half-closed, IV bag in hand. Behind her, Hakobukun waits politely in its marked pull-over box with an open smile, and Shiraishi beside it gives a small thumbs-up with her free hand, pleased."),
]

PICK = {1: '第134話_P1_20261105_060516.jpg', 2: '第134話_P2_20261105_060343.jpg', 3: '第134話_P3_20261105_060339.jpg', 4: '第134話_P4_20261105_060427.jpg'}
CLONE = {2: [((960, 1112, 1080, 1290), (890, 925)), ((960, 1112, 1080, 1290), (960, 925)), ((960, 1112, 1050, 1200), (890, 840))]}  # 背中側の袖に付いた名札を、下の網点で消す
```


## 後ろ向きのハコブくんを、背中の参照の絵で固定した

組み直した第134話の2コマ目は、後ろ向きのハコブくんです。描かせると、頭の後ろに黒い格子が付いたり、背中に何もなかったりと、背中が毎回違う形になりました。これまでも後ろ向きは描くたびに違っていました。

原因は、AI に渡していた三面図（前・横・後ろを並べた設定の絵）では、後ろの絵が小さく、背中の部品が読み取りにくかったことです。そこで、後ろ姿だけを大きくした `キャラ/hakobu_back.png` を作り、コマの項目に `back=True` と書くと、この絵を添えて、背中の形を文字でも説明するようにしました。説明に書いたのは、上のドームの切り込み、3つのスイッチの箱、右に取っ手のある扉、下の光の帯と通気口です。

あわせて、ハコブくんの顔に寄るコマで、頭が別のテレビの箱のように描かれる問題（第135話の1回目）が出たので、頭の形の決まり（画面は体の上の丸い部分にはめ込まれていて、首も箱もない）も足しました。

- ねらい：何コマにも出る形を、文字ではなく絵で固定する
- 変わったこと：後ろ向きのコマでは、背中の参照の絵を必ず添えるようにした
- 見込める効果：後ろ向きのハコブくんを描き直す回数が減る見込み。次の話から、後ろ向きのコマの描き直しの枚数で確かめる

#### vbuild.py の変更点（変える前の控えとの差分）

```
--- 指示/控え/vbuild_20261105_before_back.py
+++ 道具/vbuild.py
@@ -98,6 +98,10 @@
 - the round ring on each side face is a thin flat circular light strip printed flush on the flat white side panel, exactly as on the reference sheet's side view
 - the area INSIDE the ring is the same flat white side panel as outside it — NO hole, NO opening, NO speaker cone, NO lens, NO dot or circle at its centre, NO concentric rings, NO depth or shading inside
 
+HAKOBUKUN HEAD SHAPE — EVEN IN CLOSE-UPS (2026-11-05):
+- the screen is set FLUSH into the front of the narrower rounded upper tower of the body, framed by a thin even border, exactly as on the front view of the sheet. It is NOT a separate box, NOT a TV set, NOT a thick protruding bezel, and there is NO neck
+- the upper tower widens into the lower body with soft rounded shoulders; in a close-up, copy this outline from the sheet instead of inventing a new head
+
 HAKOBUKUN BODY — ONE BLOCK:
 - the screen head sits directly on top of the body and faces the SAME direction as the front of the body
 - the round ring panel appears ONLY on the side faces of the body, never on the front"""
@@ -107,6 +111,13 @@
 - do NOT stretch it taller; do NOT squash it wider; do NOT slim it beside a human
 - Hakobukun is a WIDE, STOUT, boxy machine — when in doubt, err WIDER, never taller
 - draw Hakobukun upright, in front, three-quarter, or rear view only — NO tilted, NO foreshortened, NO low-angle or high-angle views"""
+HAKOBU_BACK = """HAKOBUKUN REAR VIEW — COPY THE ATTACHED REAR REFERENCE EXACTLY (2026-11-05: the back was drawn differently every time):
+- from behind, the screen is NOT visible. The top is a smooth rounded white dome with ONE small notch at the centre of its lower edge — NO dark grille, NO mesh, NO vents on the dome
+- under the dome, centred on the upper back: ONE small dark horizontal control box holding three small controls side by side (a round button, a small square, a rocker switch). Draw them as plain shapes with NO text
+- below it: ONE large flat rectangular hatch door with a small tab at its top centre and a short vertical handle on its RIGHT edge
+- at the bottom of the back: a dark recessed panel with ONE long horizontal light bar (drawn as a white strip) above two blocks of short horizontal vent slots
+- the white bumper base wraps around the bottom, with a dark band and small wheels below
+- the round side ring is NOT on the back. Nothing else is added, and NO writing appears anywhere"""
 TSUKAMU = """TSUKAMUKUN — TABLETOP GRIPPER ROBOT:
 - copy Tsukamukun and its work table directly from the attached reference sheets — nothing is added, and NO writing appears anywhere
 - Tsukamukun is mounted on top of the stainless-steel two-tier work table. It is NEVER on the floor, and it has NO wheels. The table stands on adjuster feet with NO casters
@@ -270,6 +281,8 @@
         refs.append(("Hakobukun robot orthographic sheet (front, side, rear)", K + "ハコブくん.png"))
         if p.get("drawer"):
             refs.append(("Hakobukun with its drawer pulled open — the drawer shape only", K + "ハコブくん_トレー開き.png"))
+        if p.get("back"):
+            refs.append(("Hakobukun REAR VIEW — the exact design of its back; copy every rear detail from this", K + "hakobu_back.png"))
     if "ツカム" in chars:
         ul.append("Tsukamukun and its work table (their labelled sheets)")
         refs.append(("Tsukamukun tabletop gripper robot", K + "ツカムくん.png"))
@@ -340,6 +353,8 @@
                 "its width is its height divided by 1.7",
                 "both stand on the same invisible ground: the bottom of its wheels and the soles of the shoes are level",
                 "do NOT enlarge Hakobukun to balance the composition, and do NOT shrink it to waist height"]))
+    if "ハコブ" in chars and p.get("back"):
+        out.append(HAKOBU_BACK)
     if "ツカム" in chars:
         out.append(TSUKAMU)
     if "ピカリ" in chars:
@@ -589,6 +604,8 @@
                 X = round(x0 * 1080 + (bw - side) / 2); Y = round(y0 * 1350 + (bh - side) / 2)
                 img.paste(win, (X, Y))
                 dr = ImageDraw.Draw(img); dr.rectangle((X, Y, X + side - 1, Y + side - 1), outline=(0, 0, 0), width=6)
+        for (sx0, sy0, sx1, sy1), (dx, dy) in getattr(m, "CLONE", {}).get(i, []):   # 仕上げの座標で、近くの網点を写して余計な物を消す（2026-11-05 背中の名札）
+            img.paste(img.crop((sx0, sy0, sx1, sy1)), (dx, dy))
         for box in getattr(m, "PATCH", {}).get(i, []):   # 仕上げの座標（1080×1350）を白で塗る：余計な字・崩れた名札（2026-10-31）
             ImageDraw.Draw(img).rectangle(box, fill=(255, 255, 255))
         img.save(d + "/仕上げ/第%d話_P%d_1080x1350.png" % (ep, i))
```

#### vbuild.py（変更後の全文）

```python
#!/usr/bin/env python3
"""縦長4コマ（1コマ＝4:5の1枚）のプロンプトを、縦長版のプロジェクト指示の雛形どおりに組み、Gemini で描き、仕上げる（2026-10-30）

使い方（第NN話のフォルダに spec.py を置いてから）:
  python3 vbuild.py NN prompts          # 第NN話_P{n}_プロンプト.txt と 小窓 を書く
  python3 vbuild.py NN gen [1 2 3 4 W3] # 描く（省略で全部。並べて同時に頼む）
  python3 vbuild.py NN finish           # 各コマの最新の絵（または spec の PICK）を 仕上げ/ へ（緑→白・1080×1350・小窓を重ねる）
  python3 vbuild.py NN sheet            # 4枚を並べた 確認用.jpg（小さめ）

spec.py に書くもの: EP, TITLE, PANELS=[dict(...)]×4, 任意で WINDOWS={コマ: dict(...)}, PICK={コマ: ファイル名}, PATCH={コマ: [(x0,y0,x1,y1)]}（白で塗る）
コマの dict: chars=[キャラのキー], full=[全身で写るキャラ], mouth="NORMAL|STOPPED|TROUBLED|HAPPY", drawer=True,
  hscale="部位で測る相手のキャラ" または hscale_text=[行], pikari="OPERATED|AI ASSIST|AWAY|EMPTY", pscale_text=[行],
  avatar="見せ方", vr=True, costume=[行], extra=[(見出し, [行])], prop=(見出し,[行]), layout=[行],
  camera=[行], comp=[行], bubbles=[(話者, セリフ, [行の分け方])], thought=[話者], ai_bubble=True,
  sfx=[(擬音, 位置の行)], scene="英文", inset_area=(x0,y0,x1,y1)（小窓を重ねる所・幅高さの割合）
"""
import sys, os, json, base64, time, urllib.request, urllib.error, mimetypes, datetime, importlib.util, glob, concurrent.futures
import numpy as np
from PIL import Image, ImageDraw

BASE = os.path.expanduser("~/mnt/PixelstoMotors/ハコブくん/4コママンガ作成データ")
KEYS = [os.path.expanduser("~/mnt/KIOXIA/gemini_api_key.txt"), "/Volumes/KIOXIA/gemini_api_key.txt"]
MODEL = "gemini-3.1-flash-image"
K = BASE + "/キャラ/"

# キー: (英語名, リファレンス, 札, 性別, 身長cm, 部位, 識別アンカー)
CH = {
 "大城":   ("Nurse Oshiro", "大城愛名-女性看護師.png", "Nurse Oshiro, hair pulled back into a bun (front, back, side)", "f", 165, None, "(bun)"),
 "高杉":   ("Nurse Takasugi", "高杉綾-女性看護師.png", "Nurse Takasugi, short chin-length black bob, no glasses (front, back, side)", "f", 155, None, "short chin-length black bob, no glasses"),
 "寺島":   ("Nurse Terashima", "寺島由美-女性看護師.png", "Nurse Terashima, round glasses (front, back, side)", "f", 160, None, "round glasses"),
 "田中":   ("Dr. Tanaka", "田中健二-男性医師.png", "Dr. Tanaka, senior male doctor (front, back, side)", "m", 180, None, "tall senior doctor"),
 "小芝":   ("Dr. Koshiba", "小芝優介-男性医師.png", "Dr. Koshiba, young male resident doctor (front, back, side)", "m", 167, None, "young resident doctor"),
 "白石":   ("Engineer Shiraishi", "白石莉子-女性エンジニア.png", "Engineer Shiraishi, woman with glasses and a ponytail (front, back, side)", "f", 160, None, "glasses and ponytail"),
 "槙原":   ("Engineer Makihara", "槙原颯太-男性エンジニア.png", "Engineer Makihara, young male engineer with black-rimmed glasses (front, back, side)", "m", 172, None, "young male engineer who ALWAYS wears black-rimmed glasses"),
 "森田茂": ("Mr. Morita", "森田茂-男性患者.png", "Mr. Morita, elderly male patient (front, back, side)", "m", 163, None, "elderly man"),
 "森田久美子": ("Mrs. Morita", "森田久美子-女性患者.png", "Mrs. Morita, elderly woman (front, back, side)", "f", 152, None, "elderly woman"),
 "中村拓也": ("Mr. Nakamura", "中村拓也-男性患者_入院着.jpeg", "Mr. Nakamura, sturdy male patient in hospital gown (front, back, side)", "m", 175, None, "sturdy man in hospital gown"),
 "中村千夏": ("Mrs. Nakamura (Chinatsu)", "中村千夏-女性患者.png", "Chinatsu Nakamura, visiting wife (front, back, side)", "f", 164, None, "visiting wife"),
 "林":     ("Ms. Hayashi", "林さくら-女性患者.png", "Ms. Hayashi, female patient (front, back, side)", "f", 158, None, "female patient"),
 "岩田":   ("Mr. Iwata", "岩田健太-男性患者.png", "Mr. Iwata, tall male patient (front, back, side)", "m", 181, None, "tall man"),
}
HAKOBU_H = 115
PART = [(0.62, "just above {p} navel"), (0.66, "midway between the pit of {p} stomach and {p} navel"),
        (0.715, "the pit of {p} stomach — the bottom of {p} breastbone"), (0.735, "the lower edge of {p} chest"), (9, "{p} chest")]
UPPER = {"just above {p} navel": "the pit of {p} stomach", "midway between the pit of {p} stomach and {p} navel": "{p} chest",
         "the pit of {p} stomach — the bottom of {p} breastbone": "{p} chest", "the lower edge of {p} chest": "{p} collarbones", "{p} chest": "{p} shoulders"}

def part_for(h, pro):
    r = HAKOBU_H / h
    for lim, t in PART:
        if r < lim:
            return r, t.format(p=pro), UPPER[t].format(p=pro)

HEAD = ("black and white manga panel artwork on a flat chroma-key green background, single panel, no panel border, no frame line\n"
        "the artwork bleeds to all four edges of the image")

def sec(title, lines):
    return title + "\n" + "\n".join(l if l.startswith("-") else "- " + l for l in lines)

HUMAN_F = """HUMAN PROPORTIONS — DO NOT RESTYLE:
- draw every adult woman with the SAME body proportions as her reference sheet
- the HEAD IS SMALL relative to the body. Total height is EIGHT HEAD-LENGTHS from the top of the head to the soles of the shoes — the head fits into the full standing height eight times. Do NOT draw a large head, do NOT draw a seven-head or six-head figure
- the CROTCH sits at or just below the MIDPOINT of the total height
- the TORSO IS SHORT: shoulders to crotch is about 2.5 head-lengths
- shoulder width is about 2.2 head-widths
- the NECK is SHORT: roughly one third of a head-length visible between chin and collarbone
- she is a normal adult woman of average build — do NOT enlarge the head, do NOT elongate the trunk, do NOT shorten the legs"""
HUMAN_M = """HUMAN PROPORTIONS — DO NOT RESTYLE:
- draw every adult man with the SAME body proportions and build as his reference sheet
- the HEAD IS SMALL relative to the body. Total height is EIGHT HEAD-LENGTHS from the top of the head to the soles of the shoes — the head fits into the full standing height eight times. Do NOT draw a large head, do NOT draw a seven-head or six-head figure
- the CROTCH sits at or just below the MIDPOINT of the total height
- the TORSO IS SHORT: shoulders to crotch is about 2.5 head-lengths
- shoulder width is about 2.6 head-widths — broader than a woman's
- the NECK is SHORT: roughly one third of a head-length visible between chin and collarbone
- he is an adult man with the build shown on his reference sheet — do NOT enlarge the head, do NOT elongate the trunk, do NOT shorten the legs"""
LEGS = """SINGLE SET OF LEGS — NO AFTERIMAGE:
- each person has exactly ONE pair of legs and ONE pair of shoes
- NO translucent, ghosted, doubled, or motion-blurred copy of any leg, foot, or shoe — nothing semi-transparent anywhere on the figure
- every line of the legs and shoes is solid and opaque, like the rest of the line art"""
MOUTH = {
 "NORMAL": "- NORMAL: the mouth is a small white OPEN SMILE — a half-circle with a flat top edge and a rounded bottom edge, exactly as on the reference sheet. NOT a thin curved line, NOT a wide grin, NO teeth",
 "HAPPY": "- NORMAL: the mouth is a small white OPEN SMILE — a half-circle with a flat top edge and a rounded bottom edge, exactly as on the reference sheet. NOT a thin curved line, NOT a wide grin, NO teeth",
 "STOPPED": "- STOPPED / SILENT: the mouth is a small FLAT HORIZONTAL LINE. NOT curved, NOT open",
 "TROUBLED": "- TROUBLED / UNSURE: the mouth is a small WAVY LINE — a gentle horizontal squiggle like a tilde. NOT a frown, NOT open",
}
HAKOBU_TEXT = """HAKOBUKUN — TEXT-FREE MACHINE, HIGHEST PRIORITY:
- Hakobukun's body carries NO writing of any kind. Every character of dialogue belongs inside a speech bubble
- the tablet screen at the top of Hakobukun is its FACE: a dark screentone rectangle carrying exactly three simple white shapes — two small vertical rounded-oval eyes and one small mouth between and below them. The eyes are FLAT SOLID WHITE ovals with NO pupil, NO iris, NO highlight, NO outline, NO eyelash — simple pictogram shapes, not anime eyes. Keep them small relative to the screen; the dark screen area around them stays wide and empty. The screen shows an EXPRESSION, never words
{mouth}
- the EYES DO NOT CHANGE: they stay the same two flat solid white ovals, fully round-ended, NOT narrowed, NOT half-closed, NOT curved, NOT tilted. Only the mouth carries the expression
- every surface of Hakobukun carries only the details shown on the reference sheet — nothing is added, and NO writing appears anywhere on the body
- Hakobukun has NO arms, NO hands, NO attachments of any kind{drawer}

HAKOBUKUN MOUTH — A FILLED SHAPE, NOT A LINE:
- the mouth is drawn as described above and nothing else; when it is the open smile it is a FILLED white half-disc, never a thin curved stroke

HAKOBUKUN SIDE RING — FLAT, NO HOLE:
- the round ring on each side face is a thin flat circular light strip printed flush on the flat white side panel, exactly as on the reference sheet's side view
- the area INSIDE the ring is the same flat white side panel as outside it — NO hole, NO opening, NO speaker cone, NO lens, NO dot or circle at its centre, NO concentric rings, NO depth or shading inside

HAKOBUKUN HEAD SHAPE — EVEN IN CLOSE-UPS (2026-11-05):
- the screen is set FLUSH into the front of the narrower rounded upper tower of the body, framed by a thin even border, exactly as on the front view of the sheet. It is NOT a separate box, NOT a TV set, NOT a thick protruding bezel, and there is NO neck
- the upper tower widens into the lower body with soft rounded shoulders; in a close-up, copy this outline from the sheet instead of inventing a new head

HAKOBUKUN BODY — ONE BLOCK:
- the screen head sits directly on top of the body and faces the SAME direction as the front of the body
- the round ring panel appears ONLY on the side faces of the body, never on the front"""
HAKOBU_PROP = """HAKOBUKUN PROPORTIONS — DO NOT REDRAW:
- copy Hakobukun's silhouette directly from the attached orthographic reference sheet — same body, same outline, same ratio
- height-to-width ratio is FIXED at 1.7 : 1 (height = 1.7 × width)
- do NOT stretch it taller; do NOT squash it wider; do NOT slim it beside a human
- Hakobukun is a WIDE, STOUT, boxy machine — when in doubt, err WIDER, never taller
- draw Hakobukun upright, in front, three-quarter, or rear view only — NO tilted, NO foreshortened, NO low-angle or high-angle views"""
HAKOBU_BACK = """HAKOBUKUN REAR VIEW — COPY THE ATTACHED REAR REFERENCE EXACTLY (2026-11-05: the back was drawn differently every time):
- from behind, the screen is NOT visible. The top is a smooth rounded white dome with ONE small notch at the centre of its lower edge — NO dark grille, NO mesh, NO vents on the dome
- under the dome, centred on the upper back: ONE small dark horizontal control box holding three small controls side by side (a round button, a small square, a rocker switch). Draw them as plain shapes with NO text
- below it: ONE large flat rectangular hatch door with a small tab at its top centre and a short vertical handle on its RIGHT edge
- at the bottom of the back: a dark recessed panel with ONE long horizontal light bar (drawn as a white strip) above two blocks of short horizontal vent slots
- the white bumper base wraps around the bottom, with a dark band and small wheels below
- the round side ring is NOT on the back. Nothing else is added, and NO writing appears anywhere"""
TSUKAMU = """TSUKAMUKUN — TABLETOP GRIPPER ROBOT:
- copy Tsukamukun and its work table directly from the attached reference sheets — nothing is added, and NO writing appears anywhere
- Tsukamukun is mounted on top of the stainless-steel two-tier work table. It is NEVER on the floor, and it has NO wheels. The table stands on adjuster feet with NO casters
- its face is FULLY VISIBLE and turned toward the camera: white round eyes with black pupils, and NO mouth
- folded, its arm is about twice the height of its base; fully extended, about three times"""
PIKARI = """PIKARIKUN — REMOTE-OPERATED HUMANOID ROBOT, HIGHEST PRIORITY:
- copy Pikarikun directly from the attached three-view reference sheet — same body, same outline, same proportions. Nothing is added
- Pikarikun's body and head carry NO writing of any kind. The black glass on the front of its head is its FACE, never a text display. Every character of dialogue belongs inside a speech bubble
- the whole body is white hard shell, including every joint. NO black parts on the body. NO visible neck
- the front of the head is glossy black glass with NO white frame or bezel around it, and only the thin broken white reflection line just inside its edge, as on the reference sheet
- the eyes and mouth are soft white light glowing inside the black glass. They glow softly, NOT a strong flare
- Pikarikun's eyes CHANGE with its state. Do NOT apply Hakobukun's eye rules to Pikarikun

PIKARIKUN BUILD — STOUT AND BOXY, NOT SLIM:
- Pikarikun is a STOUT, CHUNKY humanoid exactly as on its three-view sheet: a LARGE rounded-rectangle head (about one quarter of its total height), a WIDE box-shaped torso, thick arms and SHORT thick legs
- its width including both arms is about 0.6 of its height. Its torso alone is about as wide as Hakobukun's body
- do NOT make it slim, do NOT make it tall and thin, do NOT give it long legs, do NOT give it a small head
- the black glass face bulges forward as the very front of the head; the white shell behind it is slightly smaller, so NO white rim shows around the glass
- the head rests directly on the top of the torso
- the legs come straight out of the flat underside of the torso

PIKARIKUN HEIGHT — STRICT:
- Pikarikun is 150 cm tall: about 1.3 times Hakobukun's height. Standing on the same ground, the TOP of Hakobukun reaches Pikarikun's SHOULDERS (the bottom edge of its head)
- next to a woman of about 160 cm, the top of Pikarikun's head reaches her chin. It is NEVER taller than an adult. It is NEVER as short as Hakobukun"""
PSTATE = {
 "OPERATED": "- OPERATED: two vertical oval eyes glow white and the mouth shows the expression of the scene (a small smile when calm). Eye shape and mouth may change with the expression, but NEVER into thin horizontal lines or half-lit eyes. Standing and moving",
 "AI ASSIST": "- AI ASSIST: the two vertical oval eyes are lit only in their LOWER HALF; the upper half is an empty outline. The mouth is a small smile. Standing and moving",
 "AWAY": "- AWAY: the eyes are two THIN HORIZONTAL white lines, and there is NO mouth. Standing straight with both arms lowered at its sides, completely still",
 "EMPTY": "- EMPTY: the black glass is completely dark — NO eyes, NO mouth. Slumped in a sitting position, as if powered off",
}
STYLE = """STYLE — match references, avoid drift:
- flat clean line art, minimal shading
- light screentone only for clothes/hair, NO heavy hatching
- ALL shading, including on the robots, is done with dot-pattern screentone with visible halftone dots — NO smooth gradient, NO airbrush, NO 3D-render shading
- NO ink-heavy seinen/gekiga style
- NO photorealistic face shading, NO sweat-drop overdraw
- character faces stay simple and consistent with reference
- line weight: thin and even, not dramatic"""
BG = """BACKGROUNDS — FLAT CHROMA KEY GREEN:
- the entire background is filled with one completely flat, uniform chroma-key green (#00B140) — a strong, saturated green, NOT pale green, NOT mint — a solid single-tone field with NO gradient, NO shading, NO texture, NO vignette, NO lighting falloff
- the green reaches every edge of the image and fills all empty space between and around the figures
- NO corridor perspective, NO windows, NO ceiling lights, NO door frames, NO wall paneling
- NO tables, NO chairs, NO furniture{prop}
- do NOT use any green anywhere on the characters, the robots, the props, the clothing, the line art, or the speech bubbles — green appears ONLY in the background
- the boundary between the figures and the green is a clean, hard edge — NO soft blur, NO glow, NO anti-aliased halo, NO drop shadow
- the figures keep their original black-and-white manga line art with white fills and screentone
- speech bubbles stay filled with solid pure white, NOT green
- NO floor line, NO ground line, NO horizon line anywhere — the figures stand directly against the flat green
- focus is on the characters and robots, NEVER the environment"""
DLANG = """DIALOGUE LANGUAGE:
- ALL dialogue text must be rendered in Japanese exactly as written below
- DO NOT translate any line into English"""

def speech_block(p):
    L = ["the bracketed speaker labels are instructions for YOU only — they are NOT part of the artwork",
         "render ONLY the text inside 「」 as visible characters; draw NOTHING else"]
    th = p.get("thought", [])
    if p.get("ai_bubble"):
        L += ["the line spoken by [Pikarikun-AI] goes inside a plain RECTANGULAR speech bubble with square corners and a short straight tail — this is the ONLY non-round bubble in the picture",
              "every other line goes inside an ordinary rounded speech bubble — NO jagged bubbles, NO burst shapes"]
    elif th and len(th) == len(p["bubbles"]):
        L += ["every line is an inner thought: it goes inside a THOUGHT BALLOON — a cloud-edged balloon whose tail is a row of small round bubbles leading to the thinker — NO jagged bubbles, NO burst shapes"]
    elif th:
        L += ["lines spoken by " + ", ".join("[%s]" % t for t in th) + " are inner thoughts: they go inside THOUGHT BALLOONS — cloud-edged, with a tail made of a row of small round bubbles",
              "every other line goes inside an ordinary rounded speech bubble — NO jagged bubbles, NO burst shapes"]
    else:
        L += ["that text goes inside ordinary rounded speech bubbles — NO jagged bubbles, NO burst shapes"]
    L += ["NEVER draw the speaker label, the brackets, a name, or a colon inside or beside any speech bubble. NEVER draw a bubble that contains only a name",
          "the 「」 quotation marks themselves are not drawn",
          "the text in every bubble runs HORIZONTALLY, left to right, in horizontal rows — NEVER vertical (tategaki) columns, even for long lines. Every bubble is WIDER than it is tall",
          "each bubble's tail points clearly to its own speaker, as stated in COMPOSITION. If two speakers say the same words, draw two separate bubbles"]
    for sp, text, split in p["bubbles"]:
        if split and len(split) > 1:
            L.append("the bubble 「%s」 holds exactly %d short lines: %s" % (text, len(split), " / ".join(split)))
    if any("『" in t for _, t, _ in p["bubbles"]):
        L.append("the 『』 marks inside a line ARE drawn as visible characters")
    L += ["draw each line ONCE. Do NOT repeat, duplicate, or echo any line inside a bubble",
          "reproduce each 「」 line CHARACTER BY CHARACTER, exactly as many characters as written — do NOT insert, repeat, or duplicate any single character inside a word. Check every katakana",
          "letter the dialogue in Japanese manga typesetting style: kanji in a gothic face, kana in a mincho face (the \"antique\" style used in printed manga)"]
    return sec("SPEECH BUBBLE TEXT — STRICT:", L)

def yline(p):
    """並び図どおりの大きさ：全身で同じ床に立つ人とロボットを、画像の上の位置で決める（2026-10-31 エイイチの指摘「大きさがバラバラ。並び図を確認」）"""
    chars = p.get("chars", []); full = [c for c in p.get("full", []) if c in CH and c not in p.get("not_standing", [])]
    robots = [r for r in ("ハコブ", "ピカリ") if r in chars and r not in p.get("not_standing", [])]
    seated = [c for c in chars if c in CH and c in p.get("not_standing", [])]
    if p.get("no_yline") or not robots or (not full and not seated and len(robots) < 2): return None
    feet = p.get("yfeet", 92); top = p.get("ytop", 30)
    H = max([CH[c][4] for c in full] + ([150] if "ピカリ" in robots else []) + [115])
    H = max(H, 150 if "ピカリ" in robots else 0)
    k = (feet - top) / H
    L = ["everyone listed here stands upright on the SAME invisible floor: all shoes and wheels land at %d%% of the image height" % feet,
         "place these height marks FIRST, then draw each figure to fit them exactly (official height chart: Hakobukun 115 cm, Pikarikun 150 cm)"]
    for c in full:
        L.append("%s (%d cm): the TOP OF THE HEAD lands at %d%% of the image height" % (CH[c][0], CH[c][4], round(feet - k * CH[c][4])))
    if "ハコブ" in robots:
        hy = round(feet - k * 115)
        L.append("Hakobukun (115 cm): the TOP OF ITS BODY lands at %d%% of the image height%s" % (hy, (" — so it is %d%% as tall as %s" % (round(115 / CH[full[0]][4] * 100), CH[full[0]][0])) if full else ""))
        L.append("Hakobukun's height is %d%% of the image height and its width is that divided by 1.7. If it reaches anyone's chest or shoulders, it is drawn TOO LARGE" % round(k * 115))
    if "ピカリ" in robots:
        L.append("Pikarikun (150 cm, stout): the TOP OF ITS HEAD lands at %d%% of the image height" % round(feet - k * 150))
    for c in [c for c in chars if c in CH and c in p.get("not_standing", [])]:
        L.append("%s is SITTING or CROUCHING: %s head top is LOWER, at about %d%% of the image height — never higher than a standing robot's top" % (CH[c][0], "her" if CH[c][3] == "f" else "his", round(feet - k * CH[c][4] * 0.58)))
    L.append("the attached SIZE GUIDE shows these heights as light-green boxes: fit each figure to its own box, and draw NO boxes or lines")
    return sec("SIZES FIXED BY THE HEIGHT CHART — STRICT:", L)

import re as _re
def guide_img(p, path):
    """並び図どおりの大きさの枠を描いた下地。枠は背景と同じ系統の緑で描く（絵に写り込んでも、緑を白にするときに消える）。字と床の線は描かない（2026-10-31）"""
    chars = p.get("chars", []); ns = p.get("not_standing", [])
    full = [c for c in p.get("full", []) if c in CH and c not in ns]
    seated = [c for c in chars if c in CH and c in ns]
    robots = [r for r in ("ハコブ", "ピカリ") if r in chars and r not in ns]
    feet = p.get("yfeet", 92); top = p.get("ytop", 30)
    H = max([CH[c][4] for c in full] + ([150] if "ピカリ" in robots else []) + [115])
    k = (feet - top) / H
    comp = " ".join(p.get("comp", []))
    W_, H_ = 1024, 1280
    img = Image.new("RGB", (W_, H_), (0, 177, 64)); d = ImageDraw.Draw(img)
    eng = {c: CH[c][0] for c in full + seated}; eng["ハコブ"] = "Hakobukun"; eng["ピカリ"] = "Pikarikun"
    figs = full + seated + robots
    xs = {}
    for c in figs:
        nm = eng[c].split()[-1].split("(")[0]
        m = _re.search(_re.escape(nm) + r"[^.%]*?centred at about (\d+)%", comp)
        if m: xs[c] = int(m.group(1))
    rest = [c for c in figs if c not in xs]
    for i, c in enumerate(rest): xs[c] = round(100 * (i + 1) / (len(rest) + 1))
    for c in figs:
        h = CH[c][4] if c in CH else (115 if c == "ハコブ" else 150)
        if c in seated: h *= 0.58
        hp = k * h / 100 * H_
        wp = hp / 1.7 if c == "ハコブ" else (hp * 0.6 if c == "ピカリ" else (hp * 0.45 if c in seated else hp * 0.27))
        cx = xs[c] / 100 * W_; y1 = feet / 100 * H_; y0 = y1 - hp
        d.rectangle((cx - wp / 2, y0, cx + wp / 2, y1), outline=(60, 235, 120), width=6)
    img.save(path)
    return path

ANATOMY = """ANATOMY CHECK — EXACTLY TWO ARMS AND TWO LEGS:
- every person has exactly TWO arms, TWO hands and TWO legs, each clearly attached to that person's own body. Count them before finishing
- NO extra arm, hand or leg, NO limb that belongs to nobody, NO duplicated torso, NO second copy of the same person
- crutches, bags and tools are held by a visible hand of the person who uses them"""

def build(p, ep):
    chars = p.get("chars", [])
    hum = [c for c in chars if c in CH]
    out = [HEAD]
    refs = [("blank 4:5 canvas, it only sets the shape of the picture, draw nothing from it", BASE + "/下地/canvas_4x5_1024x1280.png")]
    if yline(p) and p.get("_guide"):
        refs = [("SIZE GUIDE (4:5): each light-green box marks where one figure stands and exactly how tall it is (left to right in the order given in COMPOSITION; a short wide box is a seated or crouching person). Draw each figure so its head top and feet touch the top and bottom of its box. The boxes are only a guide: do NOT draw any box, outline or line in the picture", guide_img(p, p["_guide"]))]
    if p.get("layout"):
        out.append(sec("LAYOUT — DRAW IN THIS ORDER:", p["layout"]))
    ul = []
    for c in hum:
        name, f, lab, sx, h, _, anc = CH[c]
        f = p.get("ref_override", {}).get(c, f)
        ul.append("%s (%s labelled reference sheet)%s" % (name, "her" if sx == "f" else "his", (" — " + anc) if len(hum) > 1 else ""))
        refs.append((lab, K + f))
    if "ハコブ" in chars:
        ul.append("Hakobukun (its labelled orthographic sheet: front, side, rear)")
        refs.append(("Hakobukun robot orthographic sheet (front, side, rear)", K + "ハコブくん.png"))
        if p.get("drawer"):
            refs.append(("Hakobukun with its drawer pulled open — the drawer shape only", K + "ハコブくん_トレー開き.png"))
        if p.get("back"):
            refs.append(("Hakobukun REAR VIEW — the exact design of its back; copy every rear detail from this", K + "hakobu_back.png"))
    if "ツカム" in chars:
        ul.append("Tsukamukun and its work table (their labelled sheets)")
        refs.append(("Tsukamukun tabletop gripper robot", K + "ツカムくん.png"))
        refs.append(("Tsukamukun's stainless-steel work table", K + "ツカムくん_テーブル.png"))
    if "ピカリ" in chars:
        ul.append("Pikarikun (its labelled three-view sheet)")
        refs.append(("Pikarikun remote-operated humanoid robot, three views", K + ("ピカリくん_着座.jpeg" if p.get("pikari") == "EMPTY" and p.get("seated_ref") else "ピカリくん.jpeg")))
    if "アバター" in chars:
        ul.append("Robo no Avatar-san (her labelled sheet)")
        refs.append(("Robo no Avatar-san, the woman who operates Pikarikun" + (", wearing the VR headset" if p.get("vr") else ""),
                     K + ("ロボのアバターさん_VRヘッドセット.jpeg" if p.get("vr") else "ロボのアバターさん.jpeg")))
    nfig = len(hum) + sum(1 for x in ("ハコブ", "ピカリ") if x in chars)
    if hum and nfig >= 2 and not p.get("no_lineup"):
        refs.append(("lineup sheet of relative heights — sizes only", K + "並び図_主要キャラ.png"))
        ul.append("the lineup sheet shows their relative heights")
    if "ピカリ" in chars and ("ハコブ" in chars or not hum):
        refs.append(("lineup of relative heights: a girl, Hakobukun and Pikarikun on the same ground — sizes and build only", K + "並び図_女の子_ハコブくん_ピカリくん.png"))
    for lab, f in p.get("extra_refs", []):
        refs.append((lab, f if f.startswith("/") else BASE + "/" + f))
    out.append(sec("USE ATTACHED REFERENCES — match face, hair, body proportions, robot shape, line weight, and screentone density EXACTLY. Do not stylize beyond reference.", ul))
    if len([c for c in hum if c in ("大城", "高杉", "寺島", "白石")]) >= 2:
        w = ["every reference image is labelled with the character's name. Match each character ONLY to her own labelled sheet"]
        if "高杉" in hum: w.append("Takasugi: SHORT chin-length black BOB, the hair ends at the jaw line. Her hair NEVER reaches her shoulders. NO long hair, NO ponytail, NO bun, NO glasses")
        if "大城" in hum: w.append("Oshiro: hair pulled back into a BUN at the back of her head, forehead and ears visible. NEVER a bob, NEVER hair hanging down, NO glasses")
        if "寺島" in hum: w.append("Terashima: ROUND GLASSES — the only nurse with glasses")
        if "白石" in hum: w.append("Shiraishi: glasses and a PONYTAIL; she wears her engineer's clothes from her sheet, NOT scrubs")
        out.append(sec("WHO IS WHO — HAIR IS THE KEY, NEVER SWAP:", w))
    cos = list(p.get("costume", []))
    for c in hum:
        name = CH[c][0]
        cos.append("%s wears exactly the outfit on %s reference sheet, with the same fabric tone" % (name, "her" if CH[c][3] == "f" else "his"))
    if hum:
        cos.append("every name badge and ID card is BLANK — NO letters, NO names on any badge")
    if "大城" in hum:
        cos.append("Oshiro's hair is tied up in a bun. Fill the hair as SOLID BLACK, a single flat dark mass — do NOT render as a mid-tone, do NOT build the tone out of many thin parallel strands")
    if "高杉" in hum:
        cos.append("Takasugi's hair is a SHORT chin-length black BOB exactly as on her sheet, as dark as on the sheet — do NOT lighten it to brown")
    if cos:
        out.append(sec("COSTUME CONSISTENCY — match the attached reference sheets exactly:", cos))
    full = p.get("full", [])
    if any(c in CH and CH[c][3] == "f" for c in full) or ("アバター" in full):
        out.append(HUMAN_F)
    if any(c in CH and CH[c][3] == "m" for c in full):
        out.append(HUMAN_M)
    if full:
        out.append(LEGS)
    if hum:
        out.append(ANATOMY)
    YL = yline(p)
    if YL:
        out.append(YL)
    if "ハコブ" in chars:
        out.append(HAKOBU_TEXT.format(mouth=MOUTH[p.get("mouth", "NORMAL")],
                                      drawer="\n- anything inside the open drawer is plain items with NO labels, NO text" if p.get("drawer") else ""))
        out.append(HAKOBU_PROP)
        if YL:
            pass
        elif p.get("hscale_text"):
            out.append(sec("HAKOBUKUN SCALE — STRICT:", p["hscale_text"]))
        elif p.get("hscale"):
            c = p["hscale"]; name, _, _, sx, h = CH[c][:5]
            pro = "her" if sx == "f" else "his"
            r, part, up = part_for(h, pro)
            out.append(sec("HAKOBUKUN SCALE — MEASURED AGAINST %s'S BODY, STRICT:" % name.upper(), [
                "Hakobukun is about %d%% of %s's total height" % (round(r * 100), name),
                "the TOP EDGE of Hakobukun's screen is LEVEL WITH %s" % part,
                "%s face and shoulders are all ABOVE the robot. If the screen reaches %s, it is drawn too large. If the robot only reaches the waist, it is drawn too small" % (pro.capitalize(), up),
                "its width is its height divided by 1.7",
                "both stand on the same invisible ground: the bottom of its wheels and the soles of the shoes are level",
                "do NOT enlarge Hakobukun to balance the composition, and do NOT shrink it to waist height"]))
    if "ハコブ" in chars and p.get("back"):
        out.append(HAKOBU_BACK)
    if "ツカム" in chars:
        out.append(TSUKAMU)
    if "ピカリ" in chars:
        out.append(PIKARI)
        out.append("PIKARIKUN STATE — %s:\n%s" % (p.get("pikari", "OPERATED"), PSTATE[p.get("pikari", "OPERATED")]))
        if p.get("pscale_text") and not YL:
            out.append(sec("PIKARIKUN SCALE — STRICT:", p["pscale_text"]))
    if "アバター" in chars:
        av = ["her face is NEVER visible in this picture. Do NOT draw her eyes, nose, or facial features",
              "in this picture she is shown ONLY as: " + p.get("avatar", "from behind"),
              "if any part of her face would enter the frame, turn her away or crop it out",
              "her long straight black hair is filled as SOLID BLACK, a single flat dark mass — do NOT render as a mid-tone, do NOT build the tone out of many thin parallel strands"]
        if p.get("vr"):
            av.append("she holds exactly ONE VR controller in each hand, TWO in total")
        out.append(sec("ROBO NO AVATAR-SAN — FACE NEVER SHOWN, HIGHEST PRIORITY:", av))
    if p.get("prop"):
        out.append(sec("PROP — %s, THE ONLY OBJECT ALLOWED:" % p["prop"][0], p["prop"][1]))
    for t, ls in p.get("extra", []):
        out.append(sec(t, ls))
    for t, pos in p.get("sfx", []):
        out.append(sec("SOUND EFFECT — 「%s」 ONLY:" % t, [
            "the sound effect %s is hand-lettered in bold Japanese katakana, exactly these characters, NOT translated" % t,
            "it is drawn directly on the green, OUTSIDE any speech bubble", pos]))
    out.append(STYLE)
    out.append(BG.format(prop=" other than the single prop described above" if p.get("prop") else ""))
    if p.get("bubbles"):
        out.append(DLANG)
        out.append(speech_block(p))
    cam = p.get("camera")
    if cam:
        out.append(sec("CAMERA:", cam + ["NOT a front-facing lineup. NOBODY faces the viewer directly."] if p.get("nofront", True) else cam))
    comp = list(p.get("comp", []))
    if p.get("bubbles"):
        comp.append("the speech bubbles occupy the band between %s of the image height, above the figures. They are drawn COMPLETE, each with its full rounded outline and every character visible. NO bubble touches or crosses any edge of the image. If a bubble does not fit, make the bubble SMALLER — never crop it" % p.get("band", "4% and 26%"))
        for sp, text, _ in p["bubbles"]:
            who = {"Pikarikun-AI": "Pikarikun"}.get(sp, sp)
            comp.append("the tail of the bubble 「%s」 points to %s" % (text, p.get("tails", {}).get(text, who)))
        comp.append("keep at least 8% of the image width empty on the far left and far right of the bubble band")
    if p.get("inset_area"):
        x0, y0, x1, y1 = p["inset_area"]
        comp.append("KEEP EMPTY: the area from %d%% to %d%% of the image width and from %d%% to %d%% of the image height stays plain flat green with nothing in it — no figure, no bubble, no sound effect" % (x0 * 100, x1 * 100, y0 * 100, y1 * 100))
    comp.append("nothing important — no face, no bubble, no sound effect — sits in the bottom-right area (the right 15% of the width and the bottom 40% of the height)")
    out.append(sec("COMPOSITION — 4:5 portrait, generate at 1024x1280:", comp))
    tail = p["scene"]
    for sp, text, _ in p.get("bubbles", []):
        tail += "\n[%s]「%s」" % (sp, text)
    out.append(tail)
    return "\n\n".join(out) + "\n", refs

def build_window(w):
    """小窓（操縦席）。1:1。アバターさんと操縦席まわりだけ"""
    out = [HEAD]
    vr = w.get("vr")
    refs = [("blank 1:1 canvas, it only sets the shape of the picture, draw nothing from it", BASE + "/下地/canvas_1x1_1024x1024.png"),
            ("Robo no Avatar-san, the woman who operates Pikarikun" + (", wearing the VR headset" if vr else ""),
             K + ("ロボのアバターさん_VRヘッドセット.jpeg" if vr else "ロボのアバターさん.jpeg"))]
    out.append(sec("USE ATTACHED REFERENCES — match hair, clothes, body proportions, line weight, and screentone density EXACTLY. Do not stylize beyond reference.",
                   ["Robo no Avatar-san (her labelled sheet)"]))
    av = ["her face is NEVER visible in this picture. Do NOT draw her eyes, nose, or facial features",
          "in this picture she is shown ONLY as: " + w["avatar"],
          "if any part of her face would enter the frame, turn her away or crop it out",
          "her long straight black hair is filled as SOLID BLACK, a single flat dark mass — do NOT render as a mid-tone, do NOT build the tone out of many thin parallel strands"]
    if vr: av.append("she holds exactly ONE VR controller in each hand, TWO in total")
    if w.get("controller"): av.append(w["controller"])
    out.append(sec("ROBO NO AVATAR-SAN — FACE NEVER SHOWN, HIGHEST PRIORITY:", av))
    out.append(sec("NO ROBOT IN THIS PICTURE:", ["Pikarikun does NOT appear. NO robot appears anywhere. This is her own room far from the hospital"]))
    if w.get("prop"):
        out.append(sec("PROP — %s, AT MOST TWO OBJECTS:" % w["prop"][0], w["prop"][1]))
    for t, ls in w.get("extra", []):
        out.append(sec(t, ls))
    out.append(STYLE)
    out.append(BG.format(prop=" other than the props described above" if w.get("prop") else ""))
    if w.get("bubbles"):
        out.append(DLANG)
        out.append(speech_block(w))
    comp = list(w.get("comp", []))
    if w.get("bubbles"):
        comp.append("the speech bubble sits in the band between 4% and 28% of the image height, drawn COMPLETE, NOT touching any edge. Its tail points to Avatar-san")
    out.append(sec("COMPOSITION — 1:1 square, generate at 1024x1024:", comp))
    tail = w["scene"]
    for sp, text, _ in w.get("bubbles", []):
        tail += "\n[%s]「%s」" % (sp, text)
    out.append(tail)
    return "\n\n".join(out) + "\n", refs

EDIT_RING = """Edit the attached manga panel. Keep EVERYTHING exactly the same: the composition, every character and robot, every face and pose, every speech bubble and every single character of its text, the sound effects, the line art, the screentone and the flat green background.
Change ONLY this: on Hakobukun (the white boxy delivery robot with a screen face), the round ring on its side face must be a thin flat circular light strip printed flush on a flat white panel, as on the side view of the attached reference sheet. The area inside the ring is the same plain flat white panel — remove any hole, opening, speaker cone, lens, dot, inner circle or concentric rings at its centre, and any dark shading inside the ring.
Do not redraw or move anything else. If the side ring is not visible in this panel, return the panel unchanged.
"""

def epdir(ep):
    return BASE + "/縦長/第%d話" % ep

def load(ep):
    s = importlib.util.spec_from_file_location("spec%d" % ep, epdir(ep) + "/spec.py")
    m = importlib.util.module_from_spec(s); s.loader.exec_module(m); return m

def gen_one(prompt_path, out_head, refs, aspect):
    for n in range(3):
        r = _gen_one(prompt_path, out_head, refs, aspect)
        if not r.startswith("画像なし"): return r
    return r

def _gen_one(prompt_path, out_head, refs, aspect):
    key = next(open(p).read().strip() for p in KEYS if os.path.exists(p))
    parts = []
    for label, path in refs:
        mime = mimetypes.guess_type(path)[0] or "image/png"
        parts.append({"text": f"Reference image — {label}:"})
        parts.append({"inlineData": {"mimeType": mime, "data": base64.b64encode(open(path, "rb").read()).decode()}})
    parts.append({"text": open(prompt_path, encoding="utf-8").read()})
    body = {"contents": [{"role": "user", "parts": parts}],
            "generationConfig": {"responseModalities": ["IMAGE"], "imageConfig": {"aspectRatio": aspect, "imageSize": "1K"}}}
    req = urllib.request.Request(f"https://generativelanguage.googleapis.com/v1beta/models/{MODEL}:generateContent",
                                 data=json.dumps(body).encode(), headers={"Content-Type": "application/json", "x-goog-api-key": key})
    t = time.time()
    for attempt in range(3):
        try:
            res = json.load(urllib.request.urlopen(req, timeout=170)); break
        except urllib.error.HTTPError as e:
            msg = e.read().decode()[:300]
            if e.code in (429, 500, 503) and attempt < 2:
                time.sleep(15 * (attempt + 1)); continue
            return "失敗 %s %s %s" % (os.path.basename(out_head), e.code, msg)
        except Exception as e:
            if attempt < 2: time.sleep(10); continue
            return "失敗 %s %s" % (os.path.basename(out_head), e)
    stamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
    img = None
    for c in res.get("candidates", []):
        for p in c.get("content", {}).get("parts", []):
            if "inlineData" in p: img = p["inlineData"]
    base = f"{out_head}_{stamp}"
    ext = ".jpg" if img and "jpeg" in img["mimeType"] else ".png"
    if img: open(base + ext, "wb").write(base64.b64decode(img["data"]))
    for c in res.get("candidates", []):
        for p in c.get("content", {}).get("parts", []): p.pop("inlineData", None)
    json.dump({"model": MODEL, "elapsed_s": round(time.time() - t, 1), "prompt": prompt_path, "refs": [f"{a}={b}" for a, b in refs], "response": res},
              open(base + ".json", "w", encoding="utf-8"), ensure_ascii=False, indent=1)
    return (os.path.basename(base + ext) if img else "画像なし " + os.path.basename(out_head)) + " %.0f秒" % (time.time() - t)

def jobs(ep, m, which):
    d = epdir(ep); J = []
    for i, p in enumerate(m.PANELS, 1):
        if which and str(i) not in which: continue
        J.append((d + "/第%d話_P%d_プロンプト.txt" % (ep, i), d + "/生成/第%d話_P%d" % (ep, i), json.load(open(d + "/第%d話_P%d_refs.json" % (ep, i))), "4:5"))
    for k in getattr(m, "WINDOWS", {}):
        if which and "W%d" % k not in which: continue
        J.append((d + "/第%d話_P%d_小窓_プロンプト.txt" % (ep, k), d + "/生成/第%d話_P%d小窓" % (ep, k), json.load(open(d + "/第%d話_P%d小窓_refs.json" % (ep, k))), "1:1"))
    return J

def latest(d, head):
    fs = sorted(glob.glob(d + "/生成/" + head + "_2*.jpg") + glob.glob(d + "/生成/" + head + "_2*.png"))
    return fs[-1] if fs else None

def _erase_lines(a):
    """緑の上に描かれた細い白い線（ガイドの床の線・枠）を緑に戻す"""
    g = (a[..., 1] - (a[..., 0] + a[..., 2]) / 2) > 60
    wht = a.min(axis=2) > 200
    up = np.roll(g, 3, 0); dn = np.roll(g, -3, 0); lf = np.roll(g, 3, 1); rt = np.roll(g, -3, 1)
    line = wht & ((up & dn) | (lf & rt))
    a[line] = (0, 177, 64)
    return a

def resize_figs(a, fixes):
    """並び図どおりに直す：緑の上の1人（またはロボット）を、足もとを中心に拡大・縮小する（2026-10-31）
    fixes: [(x0, x1, ymin, 倍率)] … 幅の x0〜x1 の中で、緑でないかたまりのうち下のほう（高さの半分より下）まで届くもの＝人・ロボット。
    宙に浮いた吹き出しは動かさない。ymin より上は切り捨てる（0 でよい）"""
    from scipy import ndimage
    H, W, _ = a.shape
    green = (a[..., 1] - (a[..., 0] + a[..., 2]) / 2) > 40
    for x0, x1, ymin, sc in fixes:
        X0, X1, Y0 = int(x0 * W), int(x1 * W), int(ymin * H)
        reg = np.zeros((H, W), bool); reg[Y0:, X0:X1] = ~green[Y0:, X0:X1]
        lab, n = ndimage.label(reg)
        m = np.zeros((H, W), bool)
        for k in range(1, n + 1):
            ys = np.nonzero(lab == k)[0]
            if ys.max() > H * 0.5 and len(ys) > 200: m |= lab == k
        if not m.any(): continue
        m = ndimage.binary_closing(m, iterations=3)
        ys, xs = np.nonzero(m)
        t, b, l, r = ys.min(), ys.max() + 1, xs.min(), xs.max() + 1
        er = ndimage.binary_dilation(m, iterations=4) & ~green | m
        er = ndimage.binary_dilation(er, iterations=2)
        er[:, :X0] = False; er[:, X1:] = False
        crop = Image.fromarray(a[t:b, l:r].astype(np.uint8))
        al = Image.fromarray((ndimage.binary_dilation(m, iterations=2)[t:b, l:r] * 255).astype(np.uint8))
        a = a.copy(); a[er] = (0, 177, 64)
        nw, nh = max(1, round((r - l) * sc)), max(1, round((b - t) * sc))
        crop = crop.resize((nw, nh), Image.LANCZOS); al = al.resize((nw, nh), Image.LANCZOS)
        cx = (l + r) / 2; nl = round(cx - nw / 2); nt = b - nh
        base = Image.fromarray(a.astype(np.uint8)); base.paste(crop, (nl, nt), al)
        a = np.asarray(base).astype(np.float64)
    return a

def measure(src):
    """緑の上の人・ロボットの大きさを測る（2026-10-31）。床に立つかたまり（下半分まで届く）を左から順に (中心x%, 頭頂y%, 足もとy%, 高さ%) で返す。
    手をつないでいる・重なっている2人は1つに数える。吹き出しは床に届かないので数えない"""
    from scipy import ndimage
    a = np.asarray(Image.open(src).convert("RGB")).astype(np.float64)
    H, W, _ = a.shape
    green = (a[..., 1] - (a[..., 0] + a[..., 2]) / 2) > 40
    lab, n = ndimage.label(ndimage.binary_opening(~green, iterations=2))
    out = []
    for k, sl in enumerate(ndimage.find_objects(lab), 1):
        ys, xs = sl
        if ys.stop < H * 0.6 or (ys.stop - ys.start) < H * 0.15 or (xs.stop - xs.start) < W * 0.05: continue
        out.append((round((xs.start + xs.stop) / 2 / W * 100), round(ys.start / H * 100), round(ys.stop / H * 100), round((ys.stop - ys.start) / H * 100)))
    return sorted(out)

def degreen(src, fixes=None):
    a = _erase_lines(np.asarray(Image.open(src).convert("RGB")).astype(np.float64))
    if fixes: a = resize_figs(a, fixes)
    k = 24
    corners = np.concatenate([a[:k, :k].reshape(-1, 3), a[:k, -k:].reshape(-1, 3), a[-k:, :k].reshape(-1, 3), a[-k:, -k:].reshape(-1, 3)])
    bg = np.median(corners, axis=0)
    if bg[1] - (bg[0] + bg[2]) / 2 < 40:  # 角が緑でない（絵が端まである）ときは、緑の決め色
        bg = np.array([0, 177, 64.0])
    m = (a[..., 0] + a[..., 2]) / 2; mg = (bg[0] + bg[2]) / 2
    alpha = np.clip((a[..., 1] - m) / (bg[1] - mg), 0, 1)
    v = np.where(alpha < 0.999, (m - alpha * mg) / np.maximum(1 - alpha, 1e-3), 255)
    v = np.clip(v, 0, 255)
    out = alpha * 255 + (1 - alpha) * v
    out = np.where(alpha > 0.85, 255, out)
    return Image.fromarray(np.repeat(out[..., None], 3, axis=2).round().astype(np.uint8))

def fit(img, W, H):
    s = max(W / img.width, H / img.height)
    img = img.resize((round(img.width * s), round(img.height * s)), Image.LANCZOS)
    l = (img.width - W) // 2; t = (img.height - H) // 2
    return img.crop((l, t, l + W, t + H))

def finish(ep, m):
    d = epdir(ep); os.makedirs(d + "/仕上げ", exist_ok=True)
    pick = getattr(m, "PICK", {})
    for i, p in enumerate(m.PANELS, 1):
        src = pick.get(i); src = d + "/生成/" + src if src else latest(d, "第%d話_P%d" % (ep, i))
        if not src: print("P%d 絵なし" % i); continue
        img = fit(degreen(src, getattr(m, "FIX", {}).get(i)), 1080, 1350)
        if i in getattr(m, "WINDOWS", {}):
            ws = pick.get("W%d" % i); ws = d + "/生成/" + ws if ws else latest(d, "第%d話_P%d小窓" % (ep, i))
            if ws:
                x0, y0, x1, y1 = p["inset_area"]
                bw = round((x1 - x0) * 1080); bh = round((y1 - y0) * 1350); side = min(bw, bh)
                win = fit(degreen(ws), side, side)
                X = round(x0 * 1080 + (bw - side) / 2); Y = round(y0 * 1350 + (bh - side) / 2)
                img.paste(win, (X, Y))
                dr = ImageDraw.Draw(img); dr.rectangle((X, Y, X + side - 1, Y + side - 1), outline=(0, 0, 0), width=6)
        for (sx0, sy0, sx1, sy1), (dx, dy) in getattr(m, "CLONE", {}).get(i, []):   # 仕上げの座標で、近くの網点を写して余計な物を消す（2026-11-05 背中の名札）
            img.paste(img.crop((sx0, sy0, sx1, sy1)), (dx, dy))
        for box in getattr(m, "PATCH", {}).get(i, []):   # 仕上げの座標（1080×1350）を白で塗る：余計な字・崩れた名札（2026-10-31）
            ImageDraw.Draw(img).rectangle(box, fill=(255, 255, 255))
        img.save(d + "/仕上げ/第%d話_P%d_1080x1350.png" % (ep, i))
        print("P%d" % i, os.path.basename(src))

def sheet(ep, scale=0.42):
    d = epdir(ep)
    ims = [Image.open(f) for f in sorted(glob.glob(d + "/仕上げ/第%d話_P*_1080x1350.png" % ep))]
    w, h = round(1080 * scale), round(1350 * scale)
    S = Image.new("RGB", (w * len(ims) + 10 * (len(ims) - 1), h), (120, 120, 120))
    for i, im in enumerate(ims):
        S.paste(im.resize((w, h), Image.LANCZOS), (i * (w + 10), 0))
    S.save(d + "/第%d話_仕上げ_4枚_確認用.jpg" % ep, quality=85)
    print(d + "/第%d話_仕上げ_4枚_確認用.jpg" % ep)

def raw_sheet(ep, heads, scale=0.42):
    """生成したままの絵を並べる（見比べ用）。heads=[P1, P3 …] の最新"""
    d = epdir(ep); ims = []
    for h in heads:
        f = latest(d, "第%d話_%s" % (ep, h))
        if f: ims.append(Image.open(f).convert("RGB"))
    w, hh = round(1024 * scale), round(1280 * scale)
    S = Image.new("RGB", (w * len(ims) + 10 * (len(ims) - 1), hh), (120, 120, 120))
    for i, im in enumerate(ims):
        S.paste(fit(im, w, hh), (i * (w + 10), 0))
    S.save(d + "/_raw.jpg", quality=85)

if __name__ == "__main__":
    ep = int(sys.argv[1]); cmd = sys.argv[2]; rest = sys.argv[3:]
    m = load(ep); d = epdir(ep); os.makedirs(d + "/生成", exist_ok=True)
    if cmd == "prompts":
        for i, p in enumerate(m.PANELS, 1):
            p["_guide"] = d + "/生成/_guide_P%d.png" % i
            txt, refs = build(p, ep)
            open(d + "/第%d話_P%d_プロンプト.txt" % (ep, i), "w", encoding="utf-8").write(txt)
            json.dump(refs, open(d + "/第%d話_P%d_refs.json" % (ep, i), "w"), ensure_ascii=False, indent=0)
            print("P%d" % i, len(txt), "字", len(refs), "枚")
        for k, w in getattr(m, "WINDOWS", {}).items():
            txt, refs = build_window(w)
            open(d + "/第%d話_P%d_小窓_プロンプト.txt" % (ep, k), "w", encoding="utf-8").write(txt)
            json.dump(refs, open(d + "/第%d話_P%d小窓_refs.json" % (ep, k), "w"), ensure_ascii=False, indent=0)
            print("W%d" % k, len(txt), "字")
    elif cmd == "gen":
        J = jobs(ep, m, rest)
        with concurrent.futures.ThreadPoolExecutor(5) as ex:
            for r in ex.map(lambda j: gen_one(*j), J): print(r)
    elif cmd == "finish":
        finish(ep, m); sheet(ep)
    elif cmd == "edit":          # 今の絵を元に、側面の輪だけ直す（python3 vbuild.py NN edit 1 3 …）
        J = []
        for i in rest:
            src = latest(d, "第%d話_P%s" % (ep, i))
            ep_ = d + "/生成/_edit_ring.txt"
            open(ep_, "w", encoding="utf-8").write(EDIT_RING)
            J.append((ep_, d + "/生成/第%d話_P%s" % (ep, i), [("the manga panel to edit", src), ("Hakobukun robot orthographic sheet — look at the SIDE view in the middle: the ring is a flat thin circle with a plain flat panel inside", K + "ハコブくん.png")], "4:5"))
        with concurrent.futures.ThreadPoolExecutor(5) as ex:
            for r in ex.map(lambda j: gen_one(*j), J): print(r)
    elif cmd == "sheet":
        sheet(ep)
    elif cmd == "measure":       # python3 vbuild.py NN measure 4 [本数]：最新の候補の大きさを測る
        pn = rest[0]; k = int(rest[1]) if len(rest) > 1 else 5
        fs = sorted(glob.glob(d + "/生成/第%d話_P%s_2*.jpg" % (ep, pn)) + glob.glob(d + "/生成/第%d話_P%s_2*.png" % (ep, pn)))[-k:]
        for f in fs:
            ms = measure(f)
            hs = [h for _, _, _, h in ms]
            print(os.path.basename(f), ms, "高さの比 %.2f" % (min(hs) / max(hs)) if len(hs) >= 2 else "")
    elif cmd == "check":         # チェック①：1コマずつの確認用の絵と、チェック項目を出す（2026-10-31）
        os.makedirs(d + "/チェック", exist_ok=True)
        for i in range(1, len(m.PANELS) + 1):
            f = d + "/仕上げ/第%d話_P%d_1080x1350.png" % (ep, i)
            if os.path.exists(f): Image.open(f).convert("RGB").resize((720, 900)).save(d + "/チェック/P%d.jpg" % i, quality=88)
        for i, p in enumerate(m.PANELS, 1):
            print("P%d 出る人: %s / 全身: %s / セリフ: %s" % (i, "・".join(p.get("chars", [])), "・".join(p.get("full", []) or []) or "-",
                  " ｜ ".join(t for _, t, _ in p.get("bubbles", [])) or "なし"))
        ck = BASE + "/指示/チェック項目と過去の間違い.md"
        txt = open(ck, encoding="utf-8").read()
        print(txt[txt.index("## チェック項目"):txt.index("## 過去の間違い")])
        print("確認用の絵:", d + "/チェック/")
    elif cmd == "raw":
        raw_sheet(ep, rest)
```


## 高杉さんの背中の名札を、近くの網点を写して消した

第134話の2コマ目では、後ろから見た高杉さんの袖に、名札が付いてしまいました。名札は胸に付けるもので、背中にはありません。描き直すと構図が変わるので、仕上げの段階で消すことにしました。

`vbuild.py` の仕上げ（緑の背景を白にして 1080×1350 にする段階）に `CLONE` を足しました。`CLONE={コマ:[((元の左,上,右,下),(写す先の左,上))]}` と書くと、近くの網点（マンガの灰色の点の模様）を切り取って、消したい所に貼ります。白で塗るだけ（これまでの `PATCH`）だと、網点の服の上に白い四角が残るためです。第136話の2コマ目でも、大城さんの背中の名札を同じ方法で消しました。


## 第135話：配膳車の三面図と、頭の形の決まり

第135話は、細い廊下でハコブくんが急に止まって経路の計算を始め、後ろで配膳車を押す寺島さんが止められる話です。もとにしたのは [ACM interactions の病院のロボットの記事](https://interactions.acm.org/archive/view/march-april-2025/the-invisible-work-of-human-robot-collaboration-when-streamlined-processes-meet-the-complexity-of-real-world-practices)（ロボットが廊下で止まって経路を計算し直し、温かい料理の台車がすれ違えず待たされた）です。

1回目は、配膳車の形がコマごとに変わり（扉の数、取っ手、色）、1コマ目のハコブくんの頭が別の箱のように描かれ、構成も単調でした。直したのは3つです。

1. 配膳車の三面図 `キャラ/配膳車.jpg` を作り、spec の `extra_refs` で毎コマ添えた
2. 上の頭の形の決まりを `vbuild.py` に足した
3. カメラワークを組み直した：1コマ目は配膳車の取っ手と湯気を手前に大きく、2コマ目は寺島さんの顔の大写しで画面を10度傾け集中線、3コマ目は床から30cm で見上げる、4コマ目はハコブくん目線100cm で広いホール

#### 第135話の spec.py

```python
EP, TITLE = 135, "考えるのは広い所で"
# v2（2026-11-05）：エイイチ「配膳台の形が変わる・1コマ目のハコブくんのデザインが違う・単調すぎる」
# 配膳車は キャラ/配膳車.jpg を毎コマ添える。カメラ：P1 配膳車の取っ手と湯気を手前に大きく／P2 寺島さんの顔の大写し・傾けた構図・集中線／P3 床すれすれの低い位置から見上げる／P4 ハコブくんの寄りと、遠くで親指を立てる槙原さん
CART_REF = [("the hospital meal cart — copy its shape exactly in every view (tall enclosed cabinet, two front doors, push handle bar on one short end, four casters)", "キャラ/配膳車.jpg")]
CART = ("MEAL CART", ["the hospital meal cart from the attached cart reference sheet — the SAME cart in every picture: a tall enclosed cabinet with two front doors, a push-handle bar on one short end, four casters. Copy it exactly. NO text, NO logo",
                     "it is about 1.4 m tall and as wide as Hakobukun"])
PANELS = [
 # 起：手前に配膳車の取っ手と湯気（温かいご飯）、その先の廊下でハコブくんが止まる
 dict(chars=["寺島", "ハコブ"], mouth="TROUBLED", no_yline=True, prop=CART, extra_refs=CART_REF,
      hscale_text=["Terashima's two hands gripping the cart's push-handle bar fill the bottom-right foreground, cut by the bottom and right edges: x 45–100%, y 72–100%; only her hands, forearms and the top edge of the cart with rising steam are visible — NOT her face",
                   "Hakobukun is about 3 m ahead in the corridor, standing still, centred at about 36% of the image width: its top at about 40% and its wheels at about 68% of the image height, seen from its front three-quarter"],
      camera=["SHOT SIZE: extreme foreground detail of the hands on the cart handle; Hakobukun full body in the middle distance",
              "CAMERA POSITION: right behind the cart's push handle, looking along the top of the cart toward the robot", "CAMERA HEIGHT: 120 cm",
              "NOT a front-facing lineup. NOBODY faces the viewer directly."],
      band="3% and 24%",
      comp=["the bubble 「経路ヲ計算中…」 sits above Hakobukun, its tail down to it"],
      bubbles=[("Hakobukun", "経路ヲ計算中…", ["経路ヲ計算中…"])],
      extra=[("HAKOBUKUN ORIENTATION:", ["Hakobukun faces camera-left in three-quarter view, its screen face and RIGHT side ring visible; a small spinning loading circle of dots floats ABOVE its head (outside the screen)"]),
             ("EFFECT:", ["three wavy lines of steam rise from the top of the cart in the foreground; short sudden-stop lines behind the robot. NO letters, NO sound effect"])],
      scene="Dinner time in a narrow corridor (not drawn). Terashima pushes the meal cart full of hot dinners — and just ahead, Hakobukun has suddenly stopped dead in the middle of the way, thinking."),
 # 承：寺島さんの顔の大写し。傾けた構図と集中線。手前の角にハコブくんの画面の端
 dict(chars=["寺島", "ハコブ"], mouth="TROUBLED", no_yline=True,
      hscale_text=["Terashima's face and shoulders are LARGE, filling the upper right: x 30–100%, her face between 26% and 56% of the image height",
                   "only the upper corner of Hakobukun's screen-head shows at the bottom-left corner, cut by the left and bottom edges: x 0–34%, y 74–100%, its wavy-mouth face partly visible"],
      camera=["SHOT SIZE: close-up of Terashima's face", "CAMERA POSITION: front-left of Terashima, 0.8 m away, the whole frame TILTED about 10° clockwise (Dutch angle) to show her panic",
              "CAMERA HEIGHT: 140 cm", "NOT a front-facing lineup. NOBODY faces the viewer directly."],
      band="3% and 24%",
      comp=["two bubbles: 「ここで止まるの？ご飯が冷めちゃう」 large at the upper left, tail to Terashima; 「計算中デス」 small at the lower left just above the robot's screen, tail to Hakobukun"],
      bubbles=[("Terashima", "ここで止まるの？ご飯が冷めちゃう", ["ここで止まるの？", "ご飯が冷めちゃう"]), ("Hakobukun", "計算中デス", ["計算中デス"])],
      extra=[("EFFECT:", ["bold radiating focus lines behind Terashima's head; a big sweat drop on her temple. NO letters, NO sound effect"]),
             ("FACE:", ["Terashima's round glasses, eyebrows pushed up in alarm, mouth open, leaning forward"])],
      scene="Terashima, alarmed, leans in: the food is getting cold and there is no way around. Hakobukun answers calmly from the corner."),
 # 転：床すれすれから見上げる。長く待った後。手前に車輪、奥で腕組みの寺島さん、ハコブくんが振り向いて明るく
 dict(chars=["寺島", "ハコブ"], mouth="HAPPY", no_yline=True, prop=CART, extra_refs=CART_REF,
      hscale_text=["Hakobukun is in the near foreground at the LEFT, turned to look back, large: x 0–48%, its top at about 34% and its wheels cut by the bottom edge",
                   "the meal cart stands right behind it at x 46–74%, from about 30% of the image height to the bottom edge",
                   "Terashima stands behind the cart at the RIGHT, x 66–98%: her head at about 14% of the image height, looking DOWN, her body partly hidden by the cart"],
      camera=["SHOT SIZE: worm's-eye view; Hakobukun and the cart large in the foreground, Terashima towering behind", "CAMERA POSITION: on the floor in front of Hakobukun's left front corner, looking UP",
              "CAMERA HEIGHT: 30 cm, looking up", "NOT a front-facing lineup. NOBODY faces the viewer directly."],
      band="3% and 26%",
      comp=["two WIDE, LOW bubbles with HORIZONTAL text: 「計算ガ、終ワリマシタ」 at the upper left (about 36% of the image width wide, 12% tall), tail down to Hakobukun; 「終わるまで、私も止まってたのよ」 at the upper right (about 46% wide, 12% tall), tail down to Terashima. NEITHER bubble is taller than it is wide"],
      bubbles=[("Hakobukun", "計算ガ、終ワリマシタ", ["計算ガ、", "終ワリマシタ"]), ("Terashima", "終わるまで、私も止まってたのよ", ["終わるまで、", "私も止まってたのよ"])],
      extra=[("HAKOBUKUN ORIENTATION:", ["Hakobukun is seen from its front-left three-quarter, its screen face turned up toward Terashima, cheerful"]),
             ("EFFECT:", ["heavy dark vertical gloom lines over Terashima; NO steam from the cart any more. NO letters"]),
             ("TERASHIMA:", ["arms folded, flat half-closed eyes behind her round glasses, deadpan, looking down at the robot"])],
      scene="Much later. Hakobukun cheerfully reports that it has finished. Terashima, drained, glares down over the cart."),
 # 結：広いホールでひと息つくハコブくんの寄り。遠くで槙原さんが親指を立てる。まわりは大きく空ける
 dict(chars=["槙原", "ハコブ"], mouth="HAPPY", no_yline=True,
      hscale_text=["Hakobukun is in the near foreground at the RIGHT, large, seen from its front-left three-quarter: x 48–100%, its top at about 40% of the image height, its lower body cut by the bottom edge",
                   "Makihara is far away, about 8 m back, at the LEFT, small, full body: his head at about 40% and his shoes at about 62% of the image height"],
      camera=["SHOT SIZE: Hakobukun close in the foreground; Makihara small in the distance; lots of empty space — a wide open hall", "CAMERA POSITION: front-left of Hakobukun, 1 m away",
              "CAMERA HEIGHT: 100 cm (Hakobukun's eye level)", "NOT a front-facing lineup. NOBODY faces the viewer directly."],
      band="3% and 30%",
      comp=["Makihara is centred at about 20% of the image width with large empty space around him",
            "the bubble 「考エルノハ、広イ所デ」 sits above Hakobukun at the upper right, its tail down to it"],
      bubbles=[("Hakobukun", "考エルノハ、広イ所デ", ["考エルノハ、", "広イ所デ"])],
      extra=[("MAKIHARA:", ["holding a plain tablet (blank screen, NO text) in one hand, giving a thumbs-up with the other, smiling"]),
             ("EFFECT:", ["a small relieved sigh puff beside Hakobukun's screen and a few soft sparkles. NO letters"])],
      scene="Fixed. In a wide open hall, Hakobukun stops to plan its route in comfort, eyes happy, relaxed. Far behind, Makihara gives a thumbs-up."),
]

PICK = {1: '第135話_P1_20261105_065722.jpg', 2: '第135話_P2_20261105_065756.jpg', 3: '第135話_P3_20261105_065952.jpg', 4: '第135話_P4_20261105_065804.jpg'}
```


## 第136話：床のレーンの線と、大城さんの位置と、3コマ目のセリフ

第136話は、病院の地下通路の床に、歩く人と台車の通り道を分ける線が引いてあるのに、ハコブくんだけがその間を走ってくる話です。もとにしたのは[デンマークの病院の地下通路での調査](https://vbn.aau.dk/ws/files/461098840/SHTI_286_SHTI210639.pdf)（線があるのにロボットがそこに留まらず、職員はロボットがどちら側か分からなかった）です。この話は、床の線が話の中心なので、線の描き方で4回直しました。

| 回 | 指摘 | 直し方 |
|---|---|---|
| 1 | 床のマークが黒だったり白だったりする。マークが並びすぎ | 線とマークは黒一色、1つのレーンにマーク1つ、と spec に書いて描き直した |
| 2 | 1コマ目は左の線が1本ない。2コマ目は2本のレーンの間を空ける。2コマ目のセリフは大城さん | 1コマ目は線を描かせず、`lanes136.py` で後から引く形を試した。2コマ目は間の空いた絵を選び、セリフを大城さんにした |
| 3 | 1コマ目は構図を変えず、大城さんが真ん中に動けばいい。真ん中とは、人のレーンとハコブくんのレーンの間ではなく、歩く人のレーンの真ん中 | 元の絵のまま、`move136.py` で大城さんだけを横へ動かした |
| 4 | 3コマ目の流れがおかしい。大城さんの私もよ！ がよくない。3コマ目のレーンは2コマ目の幅に合わせ、間をもう少し広げる | 3コマ目を正面の鉢合わせにし、セリフを変え、`lanes_p3.py` で2コマ目と同じ形の線を重ねた |

### 1コマ目：構図はそのまま、大城さんだけを横へ動かす

`move136.py` は、絵の中の太くて長い黒いかたまりを床の線として見つけ、直線を当てはめます。大城さんは、顔を含むかたまりとして切り抜き、腰の高さで、2本目と3本目の線のちょうど間に来るように横へずらします。ずらしたあとに見えてしまう、大城さんが隠していた床の線は、当てはめた直線で引き直します。ずらした量は右へ36ピクセルでした。

【画像】LOG447_02_ep136_p1_move.jpg 第136話1コマ目。左が Gemini が描いた元の絵、右が大城さんだけを歩く人のレーンの真ん中へずらした絵。構図と線はそのまま

#### move136.py

```python
"""第136話 1コマ目：構図はそのまま（071045）で、大城さんだけを歩く人のレーンの真ん中へ横に動かす（2026-11-05 エイイチ「大城が真ん中に動けばいいだけ。構図は変えないで」）
太くて長い黒＝床の線として直線を当てはめ、大城さんが隠していた所も含めて引き直す。大城さんは顔を含むかたまりとして切り抜いて横にずらす。"""
import sys, numpy as np
from PIL import Image, ImageDraw
from scipy import ndimage
src, out = sys.argv[1:3]
im = Image.open(src).convert("RGB"); W, H = im.size
a = np.asarray(im).astype(int); g = a.mean(2)
green = (a[..., 1] - (a[..., 0] + a[..., 2]) / 2) > 40
thick = ndimage.binary_opening(g < 70, structure=np.ones((11, 11)))
lab, n = ndimage.label(thick)
lines = []
for k, (sy, sx) in enumerate(ndimage.find_objects(lab), 1):
    if sy.stop - sy.start > 0.25 * H:                      # 縦に長い太い黒＝床の線
        ys, xs = np.nonzero(lab == k)
        m, c = np.polyfit(ys, xs, 1)
        wid = (lab == k).sum() / (sy.stop - sy.start)
        lines.append((c + m * H, m, c, wid, sy.start, sy.stop))
lines.sort()
linemask = np.isin(lab, [k for k, (sy, sx) in enumerate(ndimage.find_objects(lab), 1) if sy.stop - sy.start > 0.25 * H])
linemask = ndimage.binary_dilation(linemask, iterations=2) & (g < 120)
# 大城さん：線を除いた緑でないかたまりのうち、顔（左上の明るい肌）を含むもの
fg = ~green & ~linemask
lab2, n2 = ndimage.label(fg)
top = lab2[:int(0.25 * H), :int(0.5 * W)]
k0 = np.bincount(top[top > 0]).argmax()
fig = ndimage.binary_fill_holes(lab2 == k0) & ~green
ys, xs = np.nonzero(fig); foot = ys.max()
fx = xs[ys > foot - 0.04 * H].mean()
def lx(L, y): return L[1] * y + L[2]
L0, L1 = lines[1], lines[2]          # 2026-11-05 エイイチ「真ん中は人のレーンとハコブくんのレーンの間」→ 2本目と3本目の線の間
yb = int(ys.min() + 0.45 * (foot - ys.min()))           # 腰の高さ
bx = xs[np.abs(ys - yb) < 6].mean()
cx = (lx(L0, yb) + lx(L1, yb)) / 2
dx = int(round(cx - bx))
b = a.copy(); b[ndimage.binary_dilation(fig, iterations=2)] = (0, 177, 64)
lay = Image.new("L", (W, H), 0); d = ImageDraw.Draw(lay)
for L in lines:
    d.line([(lx(L, 0), 0), (lx(L, H), H)], fill=255, width=int(round(L[3])))
lay = (np.asarray(lay) > 128) & ndimage.binary_dilation(fig, iterations=4)
gb = (b[..., 1] - (b[..., 0] + b[..., 2]) / 2) > 40
b[lay & gb] = (0, 0, 0)
B = Image.fromarray(b.astype(np.uint8))
f = Image.fromarray(a.astype(np.uint8)); mk = Image.fromarray((fig * 255).astype(np.uint8))
T = (1, 0, -dx, 0, 1, 0)
B.paste(f.transform((W, H), Image.AFFINE, T), (0, 0), mk.transform((W, H), Image.AFFINE, T))
B.save(out, quality=95)
print("線", len(lines), [(int(l[0]), round(l[3])) for l in lines], "足もと", int(fx), int(foot), "→ レーンの真ん中", int(cx), "ずらす", dx, "大城さんの画素", int(fig.sum()))
```

#### lanes136.py（1コマ目の線を後から引く形。試したが使わなかった）

```python
"""第136話 1コマ目の床の線を、絵の上に正確に描く（2026-11-05）
エイイチ「左の線が1本ない・大城さんもレーンの真ん中を通る」→ 線は絵のモデルに任せず、遠近の計算で4本引く。
大城さんの絵（床に何もない版）を切り抜いて、歩く人のレーンの真ん中に立たせる。マークは別の絵（071045）から切り出した黒い形。"""
import sys, numpy as np
from PIL import Image, ImageDraw
from scipy import ndimage
src, picsrc, out = sys.argv[1:4]
im = Image.open(src).convert("RGB"); W, H = im.size
a = np.asarray(im).astype(int)
green = (a[..., 1] - (a[..., 0] + a[..., 2]) / 2) > 40
fig = ndimage.binary_fill_holes(ndimage.binary_opening(~green, iterations=2))
ys, xs = np.nonzero(fig); t0, b0, l0, r0 = ys.min(), ys.max(), xs.min(), xs.max()
fx0 = xs[ys > b0 - 0.04 * H].mean()
vx, vy = 0.50 * W, -0.35 * H     # 消える点を画面の上の外に置き、線が集まりすぎないようにする
XB = [0.02 * W, 0.42 * W, 0.58 * W, 0.98 * W]          # 下の端での4本の線
def X(x, y): return vx + (x - vx) * (y - vy) / (H - vy)
FEET, HGT = 0.80 * H, 0.60 * H                          # 大城さんの足もとと背の高さ
cx = X((XB[0] + XB[1]) / 2, FEET)                      # 歩く人のレーンの真ん中
s = HGT / (b0 - t0)
lay = Image.new("L", (W, H), 0); d = ImageDraw.Draw(lay)
for x in XB:
    for k in range(80):
        y0 = 0.10 * H + 0.90 * H * k / 80; y1 = 0.10 * H + 0.90 * H * (k + 1) / 80 + 1
        w0 = w1 = 26                          # 4本とも同じ太さ
        d.polygon([(X(x, y0) - w0 / 2, y0), (X(x, y0) + w0 / 2, y0), (X(x, y1) + w1 / 2, y1), (X(x, y1) - w1 / 2, y1)], fill=255)
def icon(name, S=400):
    """床のマーク（黒一色のピクトグラム）を描く"""
    m = Image.new("L", (S, S), 0); g = ImageDraw.Draw(m); L = lambda pts, w: g.line(pts, fill=255, width=w, joint="curve")
    if name == "walk":
        g.ellipse((175, 10, 245, 80), fill=255)                          # 頭
        L([(200, 100), (175, 230)], 52)                                  # 胴
        L([(195, 120), (120, 185), (80, 160)], 34); L([(195, 120), (260, 175), (300, 210)], 34)   # 腕
        L([(175, 225), (120, 300), (60, 380)], 40); L([(175, 225), (240, 300), (270, 385)], 40)   # 脚
    else:
        L([(20, 40), (80, 40), (130, 280), (360, 280)], 26)              # 取っ手と台
        g.polygon([(110, 110), (380, 110), (350, 250), (135, 250)], fill=255)   # かご
        g.ellipse((120, 300, 190, 370), fill=255); g.ellipse((300, 300, 370, 370), fill=255)   # 車輪
    return m.crop(m.getbbox())
def put(m, lane, y, frac):
    lw = (XB[lane + 1] - XB[lane]) * (y - vy) / (H - vy); r = lw * frac / m.width
    m = m.resize((max(1, int(m.width * r)), max(1, int(m.height * r * 0.7))))
    c = X((XB[lane] + XB[lane + 1]) / 2, y)
    lay.paste(255, (int(c - m.width / 2), int(y - m.height / 2)), m)
put(icon("walk"), 0, 0.895 * H, 0.38)                   # 歩く人：大城さんの手前
put(icon("cart"), 2, 0.82 * H, 0.50)                    # 台車：となりのレーン
base = np.zeros((H, W, 3), np.uint8); base[:] = (0, 177, 64)
base[np.asarray(lay) > 128] = (0, 0, 0)
B = Image.fromarray(base)
f = im.crop((l0, t0, r0 + 1, b0 + 1)); mk = Image.fromarray((fig[t0:b0 + 1, l0:r0 + 1] * 255).astype(np.uint8))
f = f.resize((int(f.width * s), int(f.height * s)), Image.LANCZOS); mk = mk.resize(f.size, Image.LANCZOS)
B.paste(f, (int(cx - (fx0 - l0) * s), int(FEET - f.height)), mk)
B.save(out, quality=95)
print("レーンの真ん中", int(cx), "足もと", int(FEET), "縮め", round(s, 2))
```

### 3コマ目：セリフを変え、2コマ目と同じレーンの線を重ねる

1回目の3コマ目は、大城さんとハコブくんが横に並んで同じ向きによけ、大城さんが私もよ！ と言う絵でした。2コマ目でハコブくんは向こうから2本のレーンの間を走ってきているので、3コマ目は正面から鉢合わせになるはずです。私もよ！ は、何に対しての返事なのかが分かりません。

3コマ目を、肩越しに正面で鉢合わせる絵にし、ハコブくんのヨケテイマスに対して大城さんが、だから、どっちに来るのよ！ と言う形にしました。プロットの表の3コマ目のセリフも、同じ文に書き換えました。

- ねらい：2コマ目の、どっち側なの？ という問いを、3コマ目で鉢合わせという結果につなげる
- 変わったこと：3コマ目のセリフが、私もよ！ から、だから、どっちに来るのよ！ に変わった
- 見込める効果：4コマ目の、どっち側か分かれば、よけ方も分かる、という結論が、3コマ目の困りごとからそのまま読める見込み

3コマ目は床なしで描かせ、`lanes_p3.py` で線を重ねました。線の消える点をハコブくんの体の後ろに置き、2本のレーンの間の真ん中を、ハコブくんの車輪の真ん中に合わせます。間の幅は、下の端でハコブくんの幅の約1.4倍にしました。線は奥ほど細くし、人とロボットの後ろ（緑の背景の所）にだけ描きます。

【画像】LOG447_03_ep136_p3_lanes.jpg 第136話の2コマ目と3コマ目。3コマ目は床なしで描いた絵に、2コマ目と同じ形の4本の黒い線と、歩く人と台車のマークを重ねた

#### lanes_p3.py

```python
"""第136話 3コマ目：床なしで描いた絵に、2コマ目と同じ形の床の線（4本・黒・同じ太さの決まり）とマークを重ねる（2026-11-05 エイイチ「2コマ目のレーンの幅と合わせて・間をもう少し広げる」）
消える点はハコブくんの真上。2本のレーンの間（広め）の真ん中を、ハコブくんの車輪の真ん中に合わせる。線は人とロボットの後ろ（緑のところだけ）に描く。"""
import sys, numpy as np
from PIL import Image, ImageDraw
from scipy import ndimage
src, out = sys.argv[1:3]
im = Image.open(src).convert("RGB"); W, H = im.size
a = np.asarray(im).astype(int)
green = (a[..., 1] - (a[..., 0] + a[..., 2]) / 2) > 40
fg = ndimage.binary_opening(~green, iterations=2)
lab, n = ndimage.label(fg)
# ハコブくん：右半分の、床に届く大きなかたまり
best = None
for k, (sy, sx) in enumerate(ndimage.find_objects(lab), 1):
    cx = (sx.start + sx.stop) / 2
    if cx > 0.5 * W and sy.stop > 0.5 * H and (best is None or (lab == k).sum() > best[1]): best = (k, (lab == k).sum(), sy, sx)
k, _, sy, sx = best
ys, xs = np.nonzero(lab == k); wy = ys.max(); hx = xs[ys > wy - 0.03 * H].mean()
vx, vy = hx, 0.42 * H                    # 消える点はハコブくんの体の後ろ（線がハコブくんの頭の上に出ない）
LW, GAP = 0.85 * W, 0.75 * W                     # 間はハコブくんの幅の約1.4倍（車輪の高さで）                     # 下の端でのレーンの幅と、間の幅（2コマ目より広め）
gc = hx                                           # 間の真ん中（消える点の真下なので、どの高さでもハコブくんの下）
XB = [gc - GAP / 2 - LW, gc - GAP / 2, gc + GAP / 2, gc + GAP / 2 + LW]
def X(x, y): return vx + (x - vx) * (y - vy) / (H - vy)
lay = Image.new("L", (W, H), 0); d = ImageDraw.Draw(lay)
for x in XB:
    for i in range(80):
        y0 = vy + (H - vy) * (0.06 + i / 80 * 0.94); y1 = vy + (H - vy) * (0.06 + (i + 1) / 80 * 0.94) + 1
        w0 = 4 + 44 * (y0 - vy) / (H - vy); w1 = 4 + 44 * (y1 - vy) / (H - vy)
        d.polygon([(X(x, y0) - w0 / 2, y0), (X(x, y0) + w0 / 2, y0), (X(x, y1) + w1 / 2, y1), (X(x, y1) - w1 / 2, y1)], fill=255)
def icon(name, S=400):
    m = Image.new("L", (S, S), 0); g = ImageDraw.Draw(m); L = lambda pts, w: g.line(pts, fill=255, width=w, joint="curve")
    if name == "walk":
        g.ellipse((175, 10, 245, 80), fill=255); L([(200, 100), (175, 230)], 52)
        L([(195, 120), (120, 185), (80, 160)], 34); L([(195, 120), (260, 175), (300, 210)], 34)
        L([(175, 225), (120, 300), (60, 380)], 40); L([(175, 225), (240, 300), (270, 385)], 40)
    else:
        L([(20, 40), (80, 40), (130, 280), (360, 280)], 26)
        g.polygon([(110, 110), (380, 110), (350, 250), (135, 250)], fill=255)
        g.ellipse((120, 300, 190, 370), fill=255); g.ellipse((300, 300, 370, 370), fill=255)
    return m.crop(m.getbbox())
def put(m, lane, y, frac):
    lw = (XB[lane + 1] - XB[lane]) * (y - vy) / (H - vy); r = lw * frac / m.width
    m = m.resize((max(1, int(m.width * r)), max(1, int(m.height * r * 0.6))))
    c = X((XB[lane] + XB[lane + 1]) / 2, y)
    lay.paste(255, (int(c - m.width / 2), int(y - m.height / 2)), m)
put(icon("walk"), 0, 0.93 * H, 0.22)
put(icon("cart"), 2, 0.56 * H, 0.40)
L = np.asarray(lay) > 128
b = a.copy(); b[L & green] = (0, 0, 0)
Image.fromarray(b.astype(np.uint8)).save(out, quality=95)
print("ハコブくんの車輪の真ん中", int(hx), int(wy), "下の端の線", [int(x) for x in XB])
```

#### 第136話の spec.py

```python
EP, TITLE = 136, "どっち側なの"
# 2026-11-05 カメラワーク：P1 床すれすれから2本の線と大城さん／P2 大城さんの肩なめで、真ん中を来るハコブくん／P3 真横の引きで右へ左へ（効果線）／P4 槙原さんのタブレットの手元を前景に、線どおりにすれ違う2人
LANES = ["two long parallel lanes painted on the floor, receding into the distance. EACH lane has its OWN two border lines — so there are exactly FOUR thick SOLID BLACK lines in total, all equally thick: the left lane's left and right borders, and the right lane's left and right borders",
         "between the two lanes there is a clear GAP of bare floor, about half a lane wide, with NO line inside it — the lanes do NOT share a middle line",
         "inside each lane there is only ONE large pictogram, printed in SOLID BLACK ink: a WALKING PERSON in the LEFT lane, a WHEELED CART in the RIGHT lane. Exactly ONE pictogram per lane in the whole picture — NOT a repeated row, NO second copy further away",
         "every line and pictogram on the floor is SOLID BLACK — NOT white, NOT outlined, NOT grey. NO letters, NO arrows, NO words",
         "the lanes are the ONLY thing drawn on the floor; everything else stays flat green"]
# P1 の床の線は描かせず、lanes136.py で後から描く（線の本数・位置・大城さんがレーンの真ん中を歩くことを正確にするため）
# 2026-11-05 エイイチ「マークが黒だったり白だったり（黒で統一）・並びすぎなので減らす」
PANELS = [
 dict(chars=["大城"], full=["大城"], no_yline=True, 
      camera=["SHOT SIZE: Oshiro full body walking toward the camera along the lanes", "CAMERA POSITION: low on the floor at the end of the lanes, in front of Oshiro and slightly to the left",
              "CAMERA HEIGHT: 40 cm, looking slightly up — the floor lanes stretch large in the foreground", "NOT a front-facing lineup. NOBODY faces the viewer directly."],
      comp=["Oshiro is centred at about 32% of the image width, her head at about 18% and her shoes at about 74% of the image height, walking straight toward the camera", "draw NOTHING on the floor — NO lines, NO marks, NO shadow; the floor is plain flat green (the floor lanes are added later)", "there is NO speech bubble in this picture"],
      extra=[("BODY:", ["Oshiro walks briskly, body turned 30° to camera-right, holding a small blank clipboard"])],
      scene="The hospital's basement passage (not drawn). Floor lanes divide people and carts. Nurse Oshiro walks neatly inside the walking lane."),
 dict(chars=["大城", "ハコブ"], mouth="NORMAL", no_yline=True, prop=("FLOOR LANES", LANES),
      hscale_text=["Oshiro is in the near foreground at the LEFT, seen from behind her right shoulder, cut by the left and bottom edges: x 0–40%, her head at about 24% of the image height",
                   "Hakobukun is about 4 m away, coming straight toward the camera: its top at about 46% and its wheels at about 70% of the image height, centred at about 60% of the image width — straddling the EMPTY GAP between the two lanes, belonging to neither"],
      camera=["SHOT SIZE: over-the-shoulder; Oshiro's back of head (bun) and shoulder in the foreground, Hakobukun approaching", "CAMERA POSITION: behind Oshiro's right shoulder", "CAMERA HEIGHT: 150 cm (human eye level)",
              "NOT a front-facing lineup. NOBODY faces the viewer directly."],
      band="3% and 22%",
      comp=["the bubble 「えっ、あなたはどっち側なの？」 sits at the upper LEFT, directly above Oshiro's head, and its tail points DOWN to OSHIRO's head — NOT to the robot. Oshiro is the one speaking"],
      bubbles=[("Oshiro", "えっ、あなたはどっち側なの？", ["えっ、あなたは", "どっち側なの？"])],
      extra=[("HAKOBUKUN ORIENTATION:", ["Hakobukun faces almost toward the camera, turned slightly to camera-left, its screen visible, rolling cheerfully right down the middle line"]),
             ("EFFECT:", ["a small surprise mark (three short lines) beside Oshiro's head. NO letters"])],
      scene="Hakobukun comes rolling down the middle of the passage, straddling the line between the two lanes. Oshiro, seen from behind, startled."),
 # 転（2026-11-05 組み直し）床の線は描かせず、lanes_p3.py で2コマ目と同じ線を重ねる：大城さんの背中越しに、正面から鉢合わせ。2人とも同じ側へよける。セリフは「だから、どっちに来るのよ！」（エイイチと決めた）
 dict(chars=["大城", "ハコブ"], mouth="TROUBLED", no_yline=True, 
      hscale_text=["Oshiro is in the near foreground at the LEFT, seen from behind her right shoulder, from the knees up, cut by the bottom edge: x 0–46%, her head at about 22% of the image height",
                   "Hakobukun is about 2 m ahead of her, facing her head-on, full body: its top at about 40% and its wheels at about 70% of the image height, centred at about 64% of the image width"],
      camera=["SHOT SIZE: over-the-shoulder; Oshiro from the knees up in the foreground, Hakobukun full body facing her", "CAMERA POSITION: behind Oshiro's right shoulder, looking along the lanes",
              "CAMERA HEIGHT: 160 cm (slightly above eye level, so the floor lanes are clearly visible between them)", "NOT a front-facing lineup. NOBODY faces the viewer directly."],
      band="3% and 24%",
      comp=["draw NOTHING on the floor — NO lines, NO marks, NO shadow; the floor is plain flat green (the floor lanes are added later, the same as in the previous panel)",
            "two WIDE bubbles with HORIZONTAL text: 「ヨケテイマス」 at the upper right above the robot, tail to Hakobukun; 「だから、どっちに来るのよ！」 at the upper left above Oshiro, tail to Oshiro. NEITHER bubble is taller than it is wide"],
      bubbles=[("Hakobukun", "ヨケテイマス", ["ヨケテイマス"]), ("Oshiro", "だから、どっちに来るのよ！", ["だから、", "どっちに来るのよ！"])],
      extra=[("HAKOBUKUN ORIENTATION:", ["Hakobukun faces the camera and Oshiro head-on at a slight three-quarter, its screen fully visible with a wavy troubled mouth"]),
             ("THE DANCE:", ["both of them lurch to the SAME side (camera-right) at the same moment, so they stay face to face: each has one faint afterimage outline on the left, and short curved swing lines",
                             "Oshiro: exasperated, one hand thrown out sideways, a big sweat drop. NO letters, NO sound effect"])],
      scene="Face to face in the basement passage. Oshiro steps aside — Hakobukun steps the same way; again. Because she cannot tell which side the robot belongs to, she cannot guess which way it will go."),
 dict(chars=["槙原", "大城", "ハコブ"], mouth="HAPPY", no_yline=True, prop=("FLOOR LANES", LANES),
      hscale_text=["Makihara's hands holding a tablet are in the near foreground at the bottom-right, cut by the bottom and right edges: x 55–100%, y 70–100%; only his hands, forearms and the tablet are visible",
                   "Oshiro and Hakobukun are about 4 m away in the middle distance, passing each other: Oshiro's head at about 26% and shoes at about 72%; Hakobukun's top at about 46% and wheels at about 72% of the image height"],
      camera=["SHOT SIZE: Makihara's hands and tablet in the foreground; Oshiro and Hakobukun full body beyond", "CAMERA POSITION: behind and above Makihara's hands, looking along the lanes", "CAMERA HEIGHT: 150 cm",
              "NOT a front-facing lineup. NOBODY faces the viewer directly."],
      band="3% and 24%",
      comp=["Oshiro walks in the LEFT (walking) lane at about 30% of the image width; Hakobukun rolls inside the RIGHT (cart) lane at about 58%, both moving smoothly past each other",
            "two bubbles: 「どっち側か分かれば、よけ方も分かる」 at the upper left, tail to Oshiro; 「ワタシハ、台車ノ側デス」 at the upper right, tail to Hakobukun"],
      bubbles=[("Oshiro", "どっち側か分かれば、よけ方も分かる", ["どっち側か", "分かれば、", "よけ方も分かる"]), ("Hakobukun", "ワタシハ、台車ノ側デス", ["ワタシハ、", "台車ノ側デス"])],
      extra=[("PROP — TABLET:", ["the tablet screen shows only four simple parallel lines — NO text, NO numbers"]),
             ("HAKOBUKUN ORIENTATION:", ["Hakobukun faces toward the camera at three-quarter, screen visible, rolling neatly inside its lane"])],
      scene="Makihara has drawn the floor lanes into Hakobukun's map. Now Hakobukun stays in the cart lane and Oshiro walks past in hers, relaxed and smiling."),
]

PICK = {1: '第136話_P1_20261105_064454.jpg', 2: '第136話_P2_20261105_064500.jpg', 3: '第136話_P3_20261105_064502.jpg', 4: '第136話_P4_20261105_064533.jpg'}

PICK = {1: '第136話_P1_20261105_065834.jpg', 2: '第136話_P2_20261105_065833.jpg', 3: '第136話_P3_20261105_064502.jpg', 4: '第136話_P4_20261105_065850.jpg'}  # 2026-11-05 床のマークを黒・1つずつに直した版
PICK = {1: '第136話_P1_20261105_072200_moved.jpg', 2: '第136話_P2_20261105_070958.jpg', 3: '第136話_P3_20261105_064502.jpg', 4: '第136話_P4_20261105_065850.jpg'}  # 2026-11-05 線4本・間をあける・セリフは大城
CLONE = {2: [((200, 950, 335, 1085), (200, 812))]}  # 背中の名札を下の網点で消す
# P1 は 071416（床に何も描かせない版）を lanes136.py で切り抜き、4本の線とマークの上に置いたもの（大城さんが歩く人のレーンの真ん中）
# P1 は 071045 の構図のまま、move136.py で大城さんだけを右へ36px動かし、歩く人のレーンの真ん中にした（エイイチ「構図は変えないで」）
PICK = {1: '第136話_P1_20261105_072200_moved.jpg', 2: '第136話_P2_20261105_070958.jpg', 3: '第136話_P3_20261105_072924.jpg', 4: '第136話_P4_20261105_065850.jpg'}  # 2026-11-05 3コマ目を正面の鉢合わせ・セリフ「だから、どっちに来るのよ！」に
PICK = {1: '第136話_P1_20261105_072200_moved.jpg', 2: '第136話_P2_20261105_070958.jpg', 3: '第136話_P3_20261105_073300_lanes.jpg', 4: '第136話_P4_20261105_065850.jpg'}  # 2026-11-05 3コマ目の床の線は lanes_p3.py（間を広く・ハコブくんは間の真ん中）
```

4コマ目のレーンは、直す前の絵のままです。並べて見て、通してよいとなりました。


## 第137話：毎朝同じ時刻に詰まる廊下

第137話は、朝の交代の時間、ナースステーション前の廊下で申し送りが始まり、ハコブくんがワゴンの列の前で動けなくなる話です。もとにしたのは、[病院に搬送ロボットを入れた事例の報告](https://www.hcinnovationgroup.com/home/article/13013240/hospital-goes-robotic)（シフト交代の時間に廊下で詰まった）です。カメラワークを最初から spec に書いて描いたので、この話は各コマ3枚ずつ、計12枚で決まりました。

#### 第137話の spec.py

```python
EP, TITLE = 137, "毎朝ここで"
# 2026-11-05 カメラワーク：P1 引きの全景（申し送りの2人と、奥から来るハコブくん）／P2 ハコブくんの背中なめで、よける2人と並ぶワゴン／P3 高杉さんの顔に寄る・手前にハコブくんの画面の端／P4 白石さんの腰上を手前に、遠回りして走り去るハコブくん
WAGONS = ("WAGONS", ["a row of three plain hospital wagons (small carts with shelves and casters) parked one after another along the corridor, blocking the way. NO text, NO labels"])
PANELS = [
 dict(chars=["大城", "高杉", "ハコブ"], mouth="NORMAL", no_yline=True,
      prop=("CLIPBOARDS", ["Oshiro and Takasugi each hold one plain clipboard with blank paper. NO text, NO writing on any paper"]),
      hscale_text=["Oshiro and Takasugi stand together in the middle ground at the RIGHT, full body: heads at about 32%, shoes at about 74% of the image height (Takasugi slightly shorter)",
                   "Hakobukun is far away at the LEFT, about 10 m behind them, small: its top at about 50% and wheels at about 64% of the image height"],
      camera=["SHOT SIZE: establishing wide shot — the two nurses full body, Hakobukun small in the distance", "CAMERA POSITION: front three-quarter from the right", "CAMERA HEIGHT: 160 cm",
              "NOT a front-facing lineup. NOBODY faces the viewer directly."],
      comp=["Oshiro at about 62% and Takasugi at about 80% of the image width, turned toward each other, reading their clipboards; Hakobukun at about 24%, approaching",
            "there is NO speech bubble in this picture"],
      extra=[("HAKOBUKUN ORIENTATION:", ["Hakobukun faces toward the camera at three-quarter, screen visible, rolling toward the nurses"])],
      scene="Morning shift change in front of the nurses' station (not drawn). Oshiro and Takasugi are deep in their handover, clipboards open. In the distance, Hakobukun rolls toward them."),
 dict(chars=["大城", "高杉", "ハコブ"], mouth="TROUBLED", no_yline=True, back=True, prop=WAGONS,
      hscale_text=["Hakobukun is in the near foreground at the bottom-left, seen from BEHIND, cut by the left and bottom edges: x 0–40%, its top at about 52% of the image height",
                   "Oshiro and Takasugi stand about 2 m beyond it, stepping aside to the right: heads at about 22%, cut at the knees by Hakobukun's level",
                   "the row of wagons stands a few metres beyond them, filling the corridor ahead at x 30–70%, y 36–66%"],
      camera=["SHOT SIZE: Hakobukun's back in the foreground; the two nurses waist-up and the row of wagons beyond", "CAMERA POSITION: just behind Hakobukun's rear-left corner", "CAMERA HEIGHT: 120 cm",
              "NOT a front-facing lineup. NOBODY faces the viewer directly."],
      band="3% and 18%",
      comp=["Oshiro at about 62% and Takasugi at about 82% of the image width, both stepping aside and looking at the robot",
            "the bubble 「通リマス」 at the upper left, its tail down to Hakobukun"],
      bubbles=[("Hakobukun", "通リマス", ["通リマス"])],
      extra=[("EFFECT:", ["a small stuck mark: three short lines bursting from the top of Hakobukun. NO letters"])],
      scene="Hakobukun stops in front of the two nurses and asks to pass. They step aside politely — but right ahead, wagons crowd the corridor, so the robot still cannot move."),
 dict(chars=["高杉", "ハコブ"], mouth="TROUBLED", no_yline=True,
      hscale_text=["Takasugi is LARGE in a close shot: her head and shoulders fill the right half, x 40–100%, her face between 30% and 55% of the image height",
                   "only the top corner of Hakobukun's screen-head shows at the bottom-left, cut by the left and bottom edges: x 0–40%, y 70–100%, its troubled face visible"],
      camera=["SHOT SIZE: close-up of Takasugi's face and shoulders; the edge of Hakobukun's screen in the foreground", "CAMERA POSITION: front-left of Takasugi, 1 m away", "CAMERA HEIGHT: 140 cm",
              "NOT a front-facing lineup. NOBODY faces the viewer directly."],
      band="3% and 26%",
      comp=["two bubbles: 「この時間は、毎朝こうなるのよ」 at the upper right, tail to Takasugi; 「毎朝、ココデ止マッテイマス」 at the lower left above the robot's screen, tail to Hakobukun"],
      bubbles=[("Takasugi", "この時間は、毎朝こうなるのよ", ["この時間は、", "毎朝こうなるのよ"]), ("Hakobukun", "毎朝、ココデ止マッテイマス", ["毎朝、", "ココデ止マッテイマス"])],
      extra=[("EFFECT:", ["a few drifting dots (silence ellipsis) above the robot, and soft horizontal lines in the background suggesting time passing. NO letters, NO clock"])],
      scene="Some time later, nothing has moved. Takasugi, chin in hand on her clipboard, gives a tired, knowing look sideways at the robot. Hakobukun admits, flatly, that this happens every morning."),
 dict(chars=["白石", "ハコブ"], mouth="HAPPY", no_yline=True,
      hscale_text=["Shiraishi is in the near foreground at the LEFT, waist-up, cut by the left and bottom edges: x 0–50%, her head at about 22% of the image height",
                   "Hakobukun is about 5 m away at the RIGHT, rolling away along another route, seen from its rear three-quarter: its top at about 48% and wheels at about 66% of the image height"],
      camera=["SHOT SIZE: Shiraishi waist-up in the foreground; Hakobukun small in the distance", "CAMERA POSITION: front-left of Shiraishi", "CAMERA HEIGHT: 150 cm",
              "NOT a front-facing lineup. NOBODY faces the viewer directly."],
      band="3% and 26%",
      comp=["Hakobukun is centred at about 72% of the image width, heading away to the right",
            "the bubble 「交代ノ時間ハ、遠回リシマス」 sits above Hakobukun, its tail down to it"],
      bubbles=[("Hakobukun", "交代ノ時間ハ、遠回リシマス", ["交代ノ時間ハ、", "遠回リシマス"])],
      back=True,
      extra=[("PROP — TABLET:", ["Shiraishi holds a plain tablet showing a simple blank timetable grid with one shaded row — NO text, NO numbers"]),
             ("HAKOBUKUN ORIENTATION:", ["Hakobukun is seen from its REAR three-quarter: mostly its back (see the REAR VIEW section) and part of its right side ring, as it drives away; its screen is NOT visible"]),
             ("EFFECT:", ["light speed lines trailing behind Hakobukun, and a small happy note mark above it. NO letters"])],
      scene="Shiraishi has written the shift-change time into Hakobukun's schedule. Smiling, she watches as Hakobukun happily takes the long way around through another corridor."),
]

PICK = {1: '第137話_P1_20261105_064605.jpg', 2: '第137話_P2_20261105_064610.jpg', 3: '第137話_P3_20261105_064628.jpg', 4: '第137話_P4_20261105_064613.jpg'}
```


## 4話の仕上がりと、サイト・英語版・表紙

【画像】LOG447_04_ep134_137.jpg 仕上げた第134〜137話の4コマ。上から第134話、第135話、第136話、第137話

4話を pixelstomotors.com のマンガのページに載せました。日本語のページは `src/data/manga/ep{N}.json`、英語のページは `src/data/english/ep{N}.json` に、題名・説明・各コマの説明・デザインノート（その話で起きたことが、現実のロボットの設計のどこにつながるかを3段で書いた文）を書きます。デザインノートには、もとにした調査へのリンクを付けました。

| 話 | 日本語の題名 | 英語の題名 | ページ |
|---|---|---|---|
| 134 | ゆっくり走っても、安全とは限らない | Slow Isn't Always Safe | pixelstomotors.com/manga/ep134 ・ pixelstomotors.com/english-4-koma/ep134 |
| 135 | 止まる場所が、人の仕事を止める | Where It Stops Matters | pixelstomotors.com/manga/ep135 ・ pixelstomotors.com/english-4-koma/ep135 |
| 136 | ロボットは、どっち側を通るのか | Which Side Is the Robot On? | pixelstomotors.com/manga/ep136 ・ pixelstomotors.com/english-4-koma/ep136 |
| 137 | 毎朝同じ時刻に詰まる廊下 | The Hallway That Jams Every Morning | pixelstomotors.com/manga/ep137 ・ pixelstomotors.com/english-4-koma/ep137 |

p2m-site への commit は、第134話が `59abdc9`、第135〜137話が `af85a70` です。

### 表紙

表紙は、見せ場のコマから吹き出しを消し、題名の一言を大きく重ねて作ります（LOG413）。吹き出しを消すのは Gemini の編集に任せていますが、第136話は消すときに床の物を足されたので、吹き出しを白く塗る形にしました。題名の字は Noto Sans CJK の Black、96px、色 #242424 です。

### 英語版

英語版は、描き直さずに、吹き出しの中の日本語を消して英語を入れる `道具/英語版/en_manga.py` で作ります（LOG416）。話ごとの指定は JSON で、`seed` は各吹き出しの中の1点です。第134話の3コマ目の吹き出しは、コマの端に触れていて吹き出しとして見つけられなかったので、手書き風の文字（`signs` の `hand`）にしました。

#### 英語版の指定 ep134〜137.json

```
// ep134.json
{"ep": 134, "cover_line": "BUT I'M RUNNING LATE!", "panels": [{"bubbles": [{"seed": [481, 164], "text": "Um, I'm kind of\nin a hurry here…"}], "signs": []}, {"bubbles": [], "signs": []}, {"bubbles": [{"seed": [24, 642], "text": "I am driving\nsafely."}], "signs": [{"box": [684, 44, 1050, 232], "text": "Safe, sure. But I'm the one running late!", "style": "hand"}]}, {"bubbles": [{"seed": [88, 209], "text": "After you."}, {"seed": [721, 197], "text": "Now that's\nthe safest."}], "signs": []}]}
// ep135.json
{"ep": 135, "cover_line": "THE FOOD'S GETTING COLD!", "panels": [{"bubbles": [{"seed": [321, 149], "text": "Recalculating\nroute…"}], "signs": []}, {"bubbles": [{"seed": [39, 229], "text": "You're stopping HERE?\nThe food's\ngetting cold!"}, {"seed": [62, 732], "text": "Calculating."}], "signs": []}, {"bubbles": [{"seed": [74, 204], "text": "Calculation\ncomplete."}, {"seed": [587, 150], "text": "And I was stuck\nthe whole time, too."}], "signs": []}, {"bubbles": [{"seed": [545, 237], "text": "Better to think\nsomewhere roomy."}], "signs": []}]}
// ep136.json
{"ep": 136, "cover_line": "WHICH WAY ARE YOU GOING?!", "panels": [{"bubbles": [], "signs": []}, {"bubbles": [{"seed": [90, 195], "text": "Wait, which side\nare you on?"}], "signs": []}, {"bubbles": [{"seed": [63, 175], "text": "So which way\nare you going?!"}, {"seed": [685, 183], "text": "I am dodging."}], "signs": []}, {"bubbles": [{"seed": [43, 168], "text": "Know the side,\nknow how\nto dodge."}, {"seed": [679, 161], "text": "I am on\nthe cart side."}], "signs": []}]}
// ep137.json
{"ep": 137, "cover_line": "EVERY MORNING, I STOP HERE.", "panels": [{"bubbles": [], "signs": []}, {"bubbles": [{"seed": [109, 186], "text": "Coming\nthrough."}], "signs": []}, {"bubbles": [{"seed": [453, 197], "text": "Every morning,\nit's like this."}, {"seed": [27, 677], "text": "Every morning,\nI stop here."}], "signs": []}, {"bubbles": [{"seed": [561, 196], "text": "At shift change,\nI take the\nlong way."}], "signs": []}]}
```


## YouTube ショート4本：冒頭の動きと、最初の数秒の無音

マンガは、縦長のコマを順に見せ、声と効果音を付けた YouTube ショートにもしています（LOG408）。声は [ElevenLabs](https://elevenlabs.io/)（文章を読み上げる音声合成のサービス）で、Mac の `tts_eleven.py` から作ります。動画を組み立てる道具一式は、クラウドの作業環境が新しくなっていたので、KIOXIA に置いた `_取り込み_20261030/w30.tgz` から作り直しました。

冒頭の約3.6秒は、話ごとに違う動きをするハコブくんのアニメです。第134話はのろのろ進んでから気づき、脇へ寄っておじぎ。第135〜137話は `motion13x.py` に3つ足しました。

| 話 | 冒頭の動き |
|---|---|
| 134 | のろのろ → 気づく → 脇へ寄っておじぎ |
| 135 | 急に止まって 経路ヲ計算中 → 長い計算 → 完了でにっこり |
| 136 | 右へ左へ、よけるように揺れる（ドッチ？） |
| 137 | 走ってきて 8:30 交代中 で止まり、しょんぼり → 気づいて遠回り |

### 冒頭の効果音が鳴らなかった原因

書き出したショートを見ると、最初の数秒に音がありませんでした。**原因は、作り直した作業環境で、効果音のファイルが読めていなかったことです。** 組み立てる `mkshort.py` は、動き出すとすぐに spec のフォルダへ移り（`os.chdir`）、効果音を spec からの相対の住所 `se/se_rev.mp3` で読みます。作り直した環境では、各話のフォルダに `se` がなく、読めないまま、止まらずに音なしで書き出していました。

各話のフォルダに `se` へのリンクを置き、冒頭0秒にも走る音を足して4本を作り直しました。冒頭1秒の音量を ffmpeg の volumedetect で測った結果です。

```
$ ls -la */se
lrwxrwxrwx 1 root root 8 Nov  5 17:09 第134話_オ先ニドウゾ/se -> ../../se
lrwxrwxrwx 1 root root 8 Nov  5 17:09 第135話_考えるのは広い所で/se -> ../../se
lrwxrwxrwx 1 root root 8 Nov  5 17:09 第136話_どっち側なの/se -> ../../se
lrwxrwxrwx 1 root root 8 Nov  5 17:09 第137話_毎朝ここで/se -> ../../se
$ for d in 第13[4-7]話_*; do f="$d/out_$d.mp4"; echo "== $f"; ffprobe -v error -show_entries format=duration -of csv=p=0 "$f"; ffmpeg -hide_banner -t 1.0 -i "$f" -af volumedetect -vn -f null - 2>&1 | grep -E "mean_volume|max_volume"; done
== 第134話_オ先ニドウゾ/out_第134話_オ先ニドウゾ.mp4
23.033333
[Parsed_volumedetect_0 @ 0x559bbe53e500] mean_volume: -17.5 dB
[Parsed_volumedetect_0 @ 0x559bbe53e500] max_volume: -2.1 dB
== 第135話_考えるのは広い所で/out_第135話_考えるのは広い所で.mp4
22.900000
[Parsed_volumedetect_0 @ 0x55886a99aa00] mean_volume: -16.7 dB
[Parsed_volumedetect_0 @ 0x55886a99aa00] max_volume: -1.2 dB
== 第136話_どっち側なの/out_第136話_どっち側なの.mp4
22.300000
[Parsed_volumedetect_0 @ 0x5591377dfac0] mean_volume: -17.8 dB
[Parsed_volumedetect_0 @ 0x5591377dfac0] max_volume: -1.1 dB
== 第137話_毎朝ここで/out_第137話_毎朝ここで.mp4
20.566667
[Parsed_volumedetect_0 @ 0x55591a75eec0] mean_volume: -16.7 dB
[Parsed_volumedetect_0 @ 0x55591a75eec0] max_volume: -1.3 dB
```

4本とも20〜23秒で、冒頭1秒から音が出ています。書き出した mp4 と、題名・説明の txt は、KIOXIA の `videos/_ショート専用/_未アップ/第13X話_*/` に置きました。

#### motion13x.py（第135〜137話の冒頭の動き）

```python
"""第135〜137話の冒頭の動き（2026-11-05）。話ごとに違う動き。
135 急停止して「経路ヲ計算中」→ 長い計算 → 「完了」でにっこり
136 右へ左へ、よけるように揺れる（「ドッチ？」）
137 走ってきて「8:30 交代」で止まり、しょんぼり → 気づいて遠回り（左へ走り去る）"""
import sys
import numpy as np
import motion30, motion134
import hakobu_motion as M
from hakobu_motion import T, ease, _approach

class Recalc135:
    dur = 3.6; empty = True
    def motion(self, t):
        if t < 0.7: return dict(dx=260 * (1 - ease(t / 0.7)), dy=-5 * abs(np.sin(2 * np.pi * 4 * t)))
        v = t - 0.7; k = np.exp(-v * 10) * np.cos(2 * np.pi * 5 * v)
        return dict(sx=1 + 0.05 * k, sy=1 - 0.05 * k, rot=-3 * k)
    def face(self, t):
        if t < 0.7: return ("open", "open"), None, "mid"
        if t < 2.8:
            dots = "・" * (1 + int((t - 0.7) * 3) % 3)
            return T("経路ヲ", "計算中" + dots)
        return ("happy", "happy"), None, "closed"
    def ring(self, t): return 1.0 if 0.7 <= t < 2.8 and int(t * 6) % 2 == 0 else 0.6
    def blur(self, t): return t < 0.7
    def dim(self, t): return 1.0
    def cam(self, t): return (0, 0)

class Sway136:
    dur = 3.6; empty = True
    def motion(self, t):
        if t < 0.9: return _approach(t, 0.9, 230)
        v = t - 0.9
        x = 90 * np.sin(2 * np.pi * 0.9 * v) * min(1, v / 0.3)
        return dict(dx=x, rot=-2.5 * np.cos(2 * np.pi * 0.9 * v) * min(1, v / 0.3))
    def face(self, t):
        if t < 0.9: return ("open", "open"), None, "mid"
        if t < 2.6: return T("ドッチ？")
        return ("big", "big"), None, "wave" if int(t * 9) % 2 else "wave2"
    def ring(self, t): return 0.6
    def blur(self, t): return t < 0.9
    def dim(self, t): return 1.0
    def cam(self, t): return (0, 0)

class Shift137:
    dur = 3.8; empty = True
    def motion(self, t):
        if t < 0.9: return _approach(t, 0.9, 230)
        if t < 2.6:
            u = ease((t - 1.6) / 0.6) if t > 1.6 else 0
            return dict(dy=14 * u, rot=3 * u, sy=1 - 0.03 * u)
        v = t - 2.6
        return dict(dx=-420 * ease(v / 0.9), dy=-4 * abs(np.sin(2 * np.pi * 3 * v)), rot=-1.5)
    def face(self, t):
        if t < 0.9: return ("open", "open"), None, "mid"
        if t < 1.6: return T("8:30", "交代中")
        if t < 2.6: return ("down", "down"), "trouble", "frown"
        return ("happy", "happy"), None, "closed"
    def ring(self, t): return 0.6
    def blur(self, t): return t < 0.9 or t >= 2.6
    def dim(self, t): return 1.0
    def cam(self, t): return (0, 0)

M.PATTERNS["ep135"] = Recalc135; M.PATTERNS["ep136"] = Sway136; M.PATTERNS["ep137"] = Shift137
if __name__ == "__main__":
    for arg in sys.argv[1:]:
        nm, _, kv = arg.partition(":")
        pat = M.PATTERNS[nm]()
        for item in filter(None, kv.split(",")):
            k, v = item.split("=", 1)
            if k in motion30.STR: setattr(pat, k, v)
            else: setattr(pat, k, float(v))
        M.render(pat, f"ハコブくん_{nm}.mp4")
```

#### motion134.py（第134話の冒頭の動き）

```python
"""第134話の冒頭の動き（2026-11-05）：ノロノロの安全運転 → 後ろの気配に気づく → スッと脇へ寄って、どうぞのおじぎ"""
import sys
import numpy as np
import motion30
import hakobu_motion as M
from hakobu_motion import T, ease

class Yield134:
    dur = 3.6
    empty = True
    SLOW, NOTICE, SIDE = 2.0, 2.0, 2.45
    def motion(self, t):
        if t < self.SLOW:   # 右からノロノロ（歩くより遅い）
            u = t / self.SLOW
            return dict(dx=250 * (1 - u), dy=-2 * abs(np.sin(2 * np.pi * 1.2 * t)), rot=0.4 * np.sin(2 * np.pi * 1.2 * t))
        if t < self.SIDE:   # 後ろの気配に気づいて、びくっ
            v = t - self.NOTICE
            k = np.exp(-v * 9)
            return dict(dy=-14 * k * abs(np.sin(2 * np.pi * 3 * v)), sy=1 + 0.03 * k)
        v = t - self.SIDE   # スッと左の脇へ寄って止まり、おじぎ
        x = -150 * ease(v / 0.35)
        nod = 0.05 * max(0.0, np.sin(2 * np.pi * 1.2 * (v - 0.45))) if 0.45 < v < 0.9 else 0
        return dict(dx=x, sy=1 - nod, dy=6 * nod / 0.05)
    def face(self, t):
        if t < self.SLOW: return T("安全", "運転中")
        if t < self.SIDE: return ("big", "big"), None, "o"
        return ("happy", "happy"), None, "closed"
    def ring(self, t): return 0.6
    def blur(self, t): return self.SIDE <= t < self.SIDE + 0.35
    def dim(self, t): return 1.0
    def cam(self, t): return (0, 0)

M.PATTERNS["ep134"] = Yield134
if __name__ == "__main__":
    for arg in sys.argv[1:]:
        nm, _, kv = arg.partition(":")
        pat = M.PATTERNS[nm]()
        for item in filter(None, kv.split(",")):
            k, v = item.split("=", 1)
            if k in motion30.STR: setattr(pat, k, v)
            else: setattr(pat, k, float(v))
        M.render(pat, f"ハコブくん_{nm}.mp4")
```

#### spec_ep134.py

```python
import os, sys
sys.path.insert(0, os.environ.get("W24", os.path.expanduser("~/w")) + "/specs"); from common24 import *
import common24; common24.BGMAP[134] = "第63話.png"
HERE = os.path.dirname(os.path.abspath(__file__)); d = lambda l, e=2.0: vlen(HERE, l["v"], e)
NAME, EP = "第134話_オ先ニドウゾ", 134
HEAD = ["安全運転なのに", "なぜ怒られる？"]
COMIC = C + "ep134.jpg"                    # 縦長のコマ（1080×1350）を P1〜P4 の順に積んだ画像
P1, P2, P3, P4 = [(0, 1350 * i, 1080, 1350 * (i + 1)) for i in range(4)]
DUR = 3.6
MOTION = mot("ep134", EP)                  # 冒頭の動き（motion134.py：ノロノロ→気づく→脇へ寄っておじぎ）
SCENES = [
  dict(kind="op", video=anim(EP, "ep134"), dur=DUR, lines=[], se=[dict(at=0.05, f="se/se_rev.mp3", db=-14), dict(at=2.0, f="se/se_pikon.mp3", db=-10), dict(at=2.45, f="se/se_dash.mp3", db=-12)]),
  dict(kind="panel", crop=P1, fit=False, lines=[ns("高杉", 1, "ちょっと|【急いでる】んだけど…")]),
  dict(kind="panel", crop=P2, fit=False, min=2.2, se=[dict(at=0.3, f="se/se_dash.mp3", db=-12), dict(at=0.55, f="se/se_dash.mp3", db=-12)], words=[W_("右へ…", 0.1, 300, 240, 110, "orange", -6), W_("こっちも右！？", 0.9, 560, 380, 90, "orange", 4)]),
  dict(kind="panel", crop=P3, fit=False, lines=[ns("高杉", 2, "安全運転なのは分かるけど、|【私が遅れてる】！"), hk(1, "ワタシハ、|【安全ニ走ッテイマス】")]),
  dict(kind="panel", crop=P4, fit=False, lines=[hk(2, "【オ先ニドウゾ】"), ns("高杉", 3, "それが|【一番安全】")]),
  end(("遅く走るだけでは", 74, "ink", 0.0), ("安全にならない", 74, "ink", 0.25), ("譲る場所", 120, "orange", 0.9), ("を作る", 120, "orange", 0.9), ("（追い越せる場所と、脇へ寄る動き）", 34, "gray", 1.8)),
]
```

#### spec_ep135.py

```python
import os, sys
sys.path.insert(0, os.environ.get("W24", os.path.expanduser("~/w")) + "/specs"); from common24 import *
import common24
common24.BGMAP[135] = "第48話.png"
HERE = os.path.dirname(os.path.abspath(__file__))
NAME, EP = "第135話_考えるのは広い所で", 135
HEAD = ["ロボットが止まると", "ご飯が冷める？"]
COMIC = C + "ep135.jpg"
P1, P2, P3, P4 = [(0, 1350 * i, 1080, 1350 * (i + 1)) for i in range(4)]
DUR = 3.6
MOTION = mot("ep135", EP)                  # motion13x.py：急停止→経路ヲ計算中→完了でにっこり
SCENES = [
  dict(kind="op", video=anim(EP, "ep135"), dur=DUR, lines=[], se=[dict(at=0.0, f="se/se_dash.mp3", db=-12), dict(at=0.7, f="se/se_skid.wav", db=-10), dict(at=2.8, f="se/se_pikon.mp3", db=-10)]),
  dict(kind="panel", crop=P1, fit=False, lines=[hk(1, "【経路ヲ計算中】…")]),
  dict(kind="panel", crop=P2, fit=False, lines=[ns("寺島", 1, "ここで止まるの？|【ご飯が冷めちゃう】"), hk(2, "計算中デス")]),
  dict(kind="panel", crop=P3, fit=False, lines=[hk(3, "計算ガ、|終ワリマシタ"), ns("寺島", 2, "終わるまで、|【私も止まってた】のよ")]),
  dict(kind="panel", crop=P4, fit=False, lines=[hk(4, "考エルノハ、|【広イ所デ】")]),
  end(("迷って止まるなら", 74, "ink", 0.0), ("人の通り道の", 74, "ink", 0.25), ("外で", 120, "orange", 0.9), ("（狭い所に入る前に、広い所で道を決める）", 34, "gray", 1.8)),
]
```

#### spec_ep136.py

```python
import os, sys
sys.path.insert(0, os.environ.get("W24", os.path.expanduser("~/w")) + "/specs"); from common24 import *
import common24
common24.BGMAP[136] = "第86話.png"
HERE = os.path.dirname(os.path.abspath(__file__))
NAME, EP = "第136話_どっち側なの", 136
HEAD = ["ロボットは", "どっち側を通る？"]
COMIC = C + "ep136.jpg"
P1, P2, P3, P4 = [(0, 1350 * i, 1080, 1350 * (i + 1)) for i in range(4)]
DUR = 3.6
MOTION = mot("ep136", EP)                  # motion13x.py：右へ左へ揺れて「ドッチ？」
SCENES = [
  dict(kind="op", video=anim(EP, "ep136"), dur=DUR, lines=[], se=[dict(at=0.0, f="se/se_dash.mp3", db=-12), dict(at=1.2, f="se/se_yusa.wav", db=-10), dict(at=2.3, f="se/se_yusa.wav", db=-10)]),
  dict(kind="panel", crop=P1, fit=False, min=1.8, words=[W_("人の線", 0.2, 230, 1500, 80, "orange", 0), W_("台車の線", 0.5, 820, 1500, 80, "orange", 0)]),
  dict(kind="panel", crop=P2, fit=False, lines=[ns("大城", 1, "えっ、あなたは|【どっち側】なの？")]),
  dict(kind="panel", crop=P3, fit=False, lines=[hk(1, "ヨケテイマス"), ns("大城", 2, "だから、|【どっちに来るのよ】！")]),
  dict(kind="panel", crop=P4, fit=False, lines=[ns("大城", 3, "どっち側か分かれば、|【よけ方も分かる】"), hk(2, "ワタシハ、|【台車ノ側】デス")]),
  end(("ロボットにも", 74, "ink", 0.0), ("決まった", 74, "ink", 0.25), ("通る側", 120, "orange", 0.9), ("（施設の交通ルールの中で、見える形に）", 34, "gray", 1.8)),
]
```

#### spec_ep137.py

```python
import os, sys
sys.path.insert(0, os.environ.get("W24", os.path.expanduser("~/w")) + "/specs"); from common24 import *
import common24
common24.BGMAP[137] = "第24話.png"
HERE = os.path.dirname(os.path.abspath(__file__))
NAME, EP = "第137話_毎朝ここで", 137
HEAD = ["毎朝同じ時刻に", "止まるロボット"]
COMIC = C + "ep137.jpg"
P1, P2, P3, P4 = [(0, 1350 * i, 1080, 1350 * (i + 1)) for i in range(4)]
DUR = 3.8
MOTION = mot("ep137", EP)                  # motion13x.py：「8:30 交代中」でしょんぼり→遠回りへ走り去る
SCENES = [
  dict(kind="op", video=anim(EP, "ep137"), dur=DUR, lines=[], se=[dict(at=0.0, f="se/se_dash.mp3", db=-12), dict(at=0.9, f="se/se_pikon.mp3", db=-10), dict(at=2.6, f="se/se_dash.mp3", db=-12)]),
  dict(kind="panel", crop=P1, fit=False, min=1.8, words=[W_("朝の交代", 0.1, 540, 1500, 100, "orange", 0)]),
  dict(kind="panel", crop=P2, fit=False, lines=[hk(1, "【通リマス】")]),
  dict(kind="panel", crop=P3, fit=False, lines=[ns("高杉", 1, "この時間は、|【毎朝こうなる】のよ"), hk(2, "毎朝、|【ココデ止マッテ】イマス")]),
  dict(kind="panel", crop=P4, fit=False, lines=[hk(3, "交代ノ時間ハ、|【遠回リ】シマス")]),
  end(("ロボットの予定に", 74, "ink", 0.0), ("病院の", 74, "ink", 0.25), ("時刻表", 120, "orange", 0.9), ("（毎日混む時刻を、先に入れておく）", 34, "gray", 1.8)),
]
```


## SNS の予約：日本語は 11/28 から、英語は 1/27 から

マンガの投稿文は `r2r2r-content` の `sns/MANGA_ep{N}_sns_posts.json` に置き、予定表 `sns/schedule.json` に入れると、GitHub Actions が時刻に投稿します。日本語は空いている次の枠、英語は空いている次の平日の米国東部時間 11:00 か 15:00 です（英語は、これまでの話を順に出している列の後ろに入ります）。

| 話 | 日本語の X・LinkedIn（日本時間） | 英語の X（米国東部時間） |
|---|---|---|
| 134 | 11/28 19:30 | 1/27 15:00 |
| 135 | 11/29 19:30 | 1/28 11:00 |
| 136 | 11/30 20:00 | 1/28 15:00 |
| 137 | 12/1 20:00 | 1/29 11:00 |

r2r2r-content への commit は `c3d6068` と `28e6d00` です。ショートの X の文の型（`SHORT_ep13X` の x_template）も同じときに置きました。


## Gemini に描かせた枚数と費用

各話の `生成/` フォルダにある、Gemini が返した絵の数を数えました（プログラムで加工した絵は除く）。費用は1枚約10円で見積もった値です。

| 話 | 描いた枚数 | 費用の見積もり | 枚数が増えた理由 |
|---|---|---|---|
| 134 | 41枚 | 約410円 | 1回目を描いたあと、カメラワークを組み直して描き直した |
| 135 | 29枚 | 約290円 | 配膳車の形と構成を直して描き直した |
| 136 | 74枚 | 約740円 | 床の線を4回直した |
| 137 | 12枚 | 約120円 | 最初からカメラワークを書いて描いた |
| 計 | 156枚 | 約1,560円 | |

ふだんの1話は20枚前後です。第137話のように、描く前に spec でカメラワークを決めておけば、1話12枚で収まることがありました。逆に、第136話のように話の中心になる小道具（床の線）が絵の中でずれると、描き直しが増えます。線のように形が決まっている物は、描かせずにプログラムで引くほうが早い、というのが第136話から分かったことです。


## チェック項目と、過去の間違いの台帳に足したこと

マンガを見せる前に確かめるチェック項目（`指示/チェック項目と過去の間違い.md`）に、コマの動きの節 H と、ショートの音の確かめを足しました。同じ内容を、このプロジェクトの資料 `claude/manga_check_and_mistakes.md` にも入れています。

```
### F. 話と絵
- [ ] プロットの場面になっている（誰が・何をしている・小道具）。めくる前のコマでオチを見せていない
- [ ] 小道具が変でない（頭の上に扉が浮く、など。扉は床から立つ）
- [ ] **何コマにも出る小道具（配膳車・ワゴン・床の線など）は、同じ話の中で形と色がそろっている。** 形がぶれる物は三面図を作って `キャラ/` に置き、spec の `extra_refs` で毎コマ添える（例 `キャラ/配膳車.jpg`）
- [ ] 床の線・マークは黒で統一し、数を絞る（1つのレーンにマーク1つ）。白抜きや、マークがずらっと並ぶ絵にしない
- [ ] ガイドの白い枠や名前の札が絵に写り込んでいない

### H. コマの動き（4コマを並べて見る・2026-11-05）
- [ ] 4コマが同じ大きさ・同じ高さ・横並びの棒立ちになっていない。隣り合うコマでショットサイズとカメラの位置を変える（`指示/カメラワーク規定_縦長版.md`）
- [ ] 1話の中に、大胆な寄り（顔・手元・画面）か、手前に人やロボットを大きく切って入れる重ね（肩なめ・前景）が1コマ以上ある
- [ ] カメラの高さを変えている（人の目線150cm だけで通さない。ハコブくん目線90〜100cm・低い60cm など）
- [ ] 動き・感情の山のコマに、意図のある効果線（スピード線・集中線・縦線）が入っている
- [ ] 大きさを比べないコマ（手前と奥の重ね）は `no_yline=True` にして、hscale_text で画像の上の位置を書く。並び図の比は、同じ床・同じ距離に立つコマで守る

### G. ショート（書き出したあと）
- [ ] 冒頭の動くハコブくんは話ごとに違う動き（同じ動きを並べない）
- [ ] 声の読み・抑揚が変でない（怪しい語は --try で聞き比べる）
- [ ] 30秒以内・字幕は1行14字・2行まで
- [ ] 冒頭（最初の3秒）から音が出ている。効果音は spec のフォルダからの相対の `se/…` で読むので、作業環境では各話のフォルダに `se` のリンクを置く（ないと音なしで書き出されても止まらない）。書き出したら ffmpeg の volumedetect で冒頭の音量を測る

## 過去の間違い（2026-11-05 に足した行）

| 日付 | 話・コマ | 間違い | 見つけた人 | 直し方（プロンプト・作り方） |
|---|---|---|---|---|
| 2026-11-05 | 第134〜137話のショート | 冒頭の効果音が鳴らず、最初の数秒が無音 | エイイチ | 作り直した作業環境で、話のフォルダに se のリンクがなく効果音が読めていなかった。リンクを置き、冒頭0秒にも走る音を足して作り直した |
| 2026-11-05 | 第135話（1回目） | 配膳車の形がコマごとに違う／1コマ目のハコブくんの頭が別の箱のよう／構成が単調 | エイイチ | 配膳車の三面図（キャラ/配膳車.jpg）を作って毎コマ添えた。vbuild に HAKOBUKUN HEAD SHAPE の節を足した。顔の大写し・傾けた構図・床から見上げる・手前の取っ手と湯気、で組み直した |
| 2026-11-05 | 第136話 P1・P2・P4（1回目） | 床のマークが黒と白でばらばら・並びすぎ | エイイチ | 線とマークは黒一色、1つのレーンにマーク1つ、と spec の LANES に書いた |
| 2026-11-05 | 第134話 全コマ（1回目） | コマに動きがなく単調（4コマとも中くらいの引き・横並び・人の目線の高さ） | エイイチ | カメラワーク規定どおり、コマごとに寄り・引き・高さ・向きを変え、前景の重ねと効果線を入れて組み直した。チェック項目 H を足した |
| 2026-11-05 | 第134話 P2（1回目） | 後ろ向きのハコブくんの背中が三面図と違う（ドームに黒い格子など）。いつも違う形で描かれていた | エイイチ | hakobu_back.png を添える `back=True` と、背中の形を書いた HAKOBU_BACK の節を vbuild に足した |
| 2026-11-05 | 第134話 P2 | 後ろから見た高杉さんの袖に名札が付いた | Claude | 仕上げで近くの網点を写して消す `CLONE` を vbuild に足した |
```


## 次にやること：描く前に、spec でカメラワークを決める

第137話は、描く前に spec でコマごとのショット・位置・高さを決めたので、描き直しが少なく済みました。次の話からは、プロットから spec を書く段階で、4コマのカメラの高さとショットの大きさを先に並べ、隣り合うコマで同じにならないかを見てから描きます。床の線のように形の決まった物は、最初からプログラムで引く前提で spec に書きます。プロットの表には、まだ第138〜183話の46話が残っています。
