#!/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)