#!/usr/bin/env python3 """Gemini の画像 API でマンガの1コマを1枚だけ生成する。 使い方: python3 manga/gemini_panel.py --prompt P.txt --image canvas.png --image hakobu.png \ --out-dir OUT --model gemini-3.1-flash-image --name 第61話_P1 - 鍵は /Volumes/KIOXIA/gemini_api_key.txt から読むだけ (表示・保存しない) - 縦横比 4:5 / 大きさ 1K を generationConfig.imageConfig で指定 - 生成は1回だけ。失敗してもやり直さない - 出力: {name}_api_{model}_{日時}.{png|jpg|webp} (返ってきた形式に合わせる) と同じ名前の .json (使用量など。画像データは含めない) """ import argparse import json import mimetypes import sys import time from datetime import datetime from pathlib import Path from google import genai from google.genai import types KEY_PATH = Path("/Volumes/KIOXIA/gemini_api_key.txt") EXT_BY_MIME = {"image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp"} def load_key(): if not Path("/Volumes/KIOXIA").is_dir(): sys.exit("ABORT: KIOXIA がつながっていない") if not KEY_PATH.is_file(): sys.exit(f"ABORT: 鍵のファイルがない: {KEY_PATH}") key = KEY_PATH.read_text().strip() if not key: sys.exit("ABORT: 鍵のファイルが空") return key def strip_inline_data(obj): """返事の dict から画像のバイト列を取り除く。""" if isinstance(obj, dict): out = {} for k, v in obj.items(): if k in ("inline_data", "inlineData") and isinstance(v, dict): out[k] = {kk: vv for kk, vv in v.items() if kk != "data"} out[k]["data"] = "" elif k in ("thought_signature", "thoughtSignature"): out[k] = "" else: out[k] = strip_inline_data(v) return out if isinstance(obj, list): return [strip_inline_data(v) for v in obj] return obj def main(): ap = argparse.ArgumentParser() ap.add_argument("--prompt", required=True, type=Path) ap.add_argument("--image", action="append", default=[], type=Path, help="添付する画像 (この順で渡す。複数可)") ap.add_argument("--out-dir", required=True, type=Path) ap.add_argument("--model", required=True) ap.add_argument("--name", default="panel", help="出力ファイル名の頭") ap.add_argument("--aspect-ratio", default="4:5") ap.add_argument("--image-size", default="1K") args = ap.parse_args() prompt = args.prompt.read_text(encoding="utf-8") if not prompt.strip(): sys.exit(f"ABORT: プロンプトが空: {args.prompt}") for p in args.image: if not p.is_file(): sys.exit(f"ABORT: 画像がない: {p}") stamp = datetime.now().strftime("%Y%m%d_%H%M%S") stem = f"{args.name}_api_{args.model}_{stamp}" args.out_dir.mkdir(parents=True, exist_ok=True) json_path = args.out_dir / f"{stem}.json" if any(args.out_dir.glob(f"{stem}.*")): sys.exit(f"ABORT: 同名ファイルがある: {stem}") contents = [prompt] for p in args.image: mime = mimetypes.guess_type(p.name)[0] or "image/png" contents.append(types.Part.from_bytes(data=p.read_bytes(), mime_type=mime)) config = types.GenerateContentConfig( response_modalities=["TEXT", "IMAGE"], image_config=types.ImageConfig( aspect_ratio=args.aspect_ratio, image_size=args.image_size, ), ) client = genai.Client(api_key=load_key()) record = { "requested_model": args.model, "prompt_file": str(args.prompt), "images": [str(p) for p in args.image], "aspect_ratio": args.aspect_ratio, "image_size": args.image_size, "started_at": datetime.now().isoformat(timespec="seconds"), } t0 = time.time() try: resp = client.models.generate_content( model=args.model, contents=contents, config=config) except Exception as e: record["elapsed_sec"] = round(time.time() - t0, 2) record["error"] = f"{type(e).__name__}: {e}" json_path.write_text(json.dumps(record, ensure_ascii=False, indent=2), encoding="utf-8") print(f"ERROR: {record['error']}") print(f"json: {json_path}") sys.exit(1) record["elapsed_sec"] = round(time.time() - t0, 2) image_saved = False texts = [] for cand in resp.candidates or []: for part in (cand.content.parts if cand.content else None) or []: if part.inline_data and part.inline_data.data and not image_saved: mime = part.inline_data.mime_type or "image/png" img_path = args.out_dir / f"{stem}{EXT_BY_MIME.get(mime, '.bin')}" img_path.write_bytes(part.inline_data.data) record["image_mime_type"] = mime image_saved = True elif part.text and not part.thought: texts.append(part.text) record["model_version"] = resp.model_version record["response_id"] = resp.response_id record["usage_metadata"] = (resp.usage_metadata.model_dump(mode="json", exclude_none=True) if resp.usage_metadata else None) record["finish_reasons"] = [str(c.finish_reason) for c in resp.candidates or []] record["texts"] = texts record["image_file"] = str(img_path) if image_saved else None record["response"] = strip_inline_data(resp.model_dump(mode="json", exclude_none=True)) json_path.write_text(json.dumps(record, ensure_ascii=False, indent=2), encoding="utf-8") print(f"elapsed: {record['elapsed_sec']} s / model_version: {resp.model_version}") print(f"usage: {json.dumps(record['usage_metadata'], ensure_ascii=False)}") print(f"image: {img_path if image_saved else '(画像なし)'}") print(f"json: {json_path}") if not image_saved: sys.exit(1) if __name__ == "__main__": main()