#!/usr/bin/env python3
"""通過 API易 呼叫 Nano Banana 2 Lite(gemini-3.1-flash-lite-image)生成 / 編輯圖片。純標準庫,零依賴。"""
import argparse
import base64
import json
import os
import sys
import urllib.error
import urllib.request
from concurrent.futures import ThreadPoolExecutor
# 一次呼叫最多併發出幾張圖(邊界,避免一次性打太多請求)
MAX_COUNT = 5
def load_api_key():
"""優先讀環境變數;否則在指令碼所在目錄及其父目錄找 .env。"""
key = os.environ.get("APIYI_API_KEY")
if key:
return key
here = os.path.dirname(os.path.abspath(__file__))
for d in (here, os.path.dirname(here)):
env_path = os.path.join(d, ".env")
if os.path.exists(env_path):
with open(env_path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if line.startswith("APIYI_API_KEY") and "=" in line:
return line.split("=", 1)[1].strip().strip('"').strip("'")
return None
def project_root():
"""從指令碼位置向上找包含 .git 或 .claude 的目錄,作為專案根目錄;找不到則用當前工作目錄。"""
d = os.path.dirname(os.path.abspath(__file__))
while True:
if os.path.isdir(os.path.join(d, ".git")) or os.path.isdir(os.path.join(d, ".claude")):
return d
parent = os.path.dirname(d)
if parent == d:
return os.getcwd()
d = parent
def to_b64(path):
with open(path, "rb") as f:
return base64.b64encode(f.read()).decode()
def mime_of(path):
return "image/png" if path.lower().endswith(".png") else "image/jpeg"
def generate(api_key, endpoint, prompt, images, aspect, size):
"""發一次請求,返回圖片位元組;失敗拋 RuntimeError。"""
parts = [{"text": prompt}]
for path in images:
parts.append({"inlineData": {"mimeType": mime_of(path), "data": to_b64(path)}})
payload = json.dumps({
"contents": [{"parts": parts}],
"generationConfig": {
"responseModalities": ["IMAGE"],
"imageConfig": {"aspectRatio": aspect, "imageSize": size},
},
}).encode()
req = urllib.request.Request(
endpoint, data=payload, method="POST",
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=360) as r:
resp = json.loads(r.read())
except urllib.error.HTTPError as e:
raise RuntimeError(f"請求失敗 HTTP {e.code}:{e.read().decode(errors='replace')}")
candidates = resp.get("candidates")
if not candidates:
raise RuntimeError(f"未返回候選內容(可能被內容安全策略拒絕):{resp}")
cand = candidates[0]
# 內容稽核攔截:finishReason 非 STOP,或只返回了文字說明
if cand.get("finishReason") not in (None, "STOP"):
text = next((p.get("text") for p in cand["content"]["parts"] if p.get("text")), "")
raise RuntimeError(f"請求被拒絕(finishReason={cand.get('finishReason')}):{text}")
image_part = next((p for p in cand["content"]["parts"] if p.get("inlineData")), None)
if not image_part:
text = next((p.get("text") for p in cand["content"]["parts"] if p.get("text")), "")
raise RuntimeError(f"未返回圖片,模型回覆:{text}")
return base64.b64decode(image_part["inlineData"]["data"])
def resolve_paths(out, count):
"""決定輸出路徑列表。
- 若 out 帶目錄成分(相對/絕對),按使用者給定的路徑處理(相對則相對當前工作目錄)。
- 若 out 是純檔名,統一存到 <專案根>/nano-banana-output/ 下,確保好找。
count>1 時給檔名加 -1 / -2 … 字尾。
"""
if os.path.dirname(out):
base_path = os.path.abspath(out)
else:
out_dir = os.path.join(project_root(), "nano-banana-output")
os.makedirs(out_dir, exist_ok=True)
base_path = os.path.join(out_dir, out)
if count == 1:
return [base_path]
base, ext = os.path.splitext(base_path)
return [f"{base}-{i}{ext}" for i in range(1, count + 1)]
def main():
api_key = load_api_key()
if not api_key:
sys.exit("未找到 API Key:請在技能目錄的 .env 寫一行 APIYI_API_KEY=sk-xxx")
model = os.environ.get("APIYI_IMAGE_MODEL", "gemini-3.1-flash-lite-image")
endpoint = f"https://api.apiyi.com/v1beta/models/{model}:generateContent"
parser = argparse.ArgumentParser(description="Nano Banana Lite 出圖")
parser.add_argument("prompt", help="提示詞 / 編輯指令")
parser.add_argument("-i", "--image", action="append", default=[],
help="輸入圖片路徑(可重複,傳入即為編輯模式)")
parser.add_argument("-o", "--out", default="output.png", help="輸出檔名")
parser.add_argument("-n", "--count", type=int, default=1,
help=f"一次出幾張,預設 1,最多 {MAX_COUNT}(併發生成)")
parser.add_argument("--aspect", default="1:1", help="寬高比,14 選 1,如 16:9 / 1:4 / 8:1")
parser.add_argument("--size", default="1K", help="解析度僅支援 1K(Lite 專注 1K 畫布)")
args = parser.parse_args()
count = args.count
if count < 1:
count = 1
if count > MAX_COUNT:
print(f"提示:一次最多 {MAX_COUNT} 張,已將 {args.count} 限制為 {MAX_COUNT}。", file=sys.stderr)
count = MAX_COUNT
paths = resolve_paths(args.out, count)
def task(path):
data = generate(api_key, endpoint, args.prompt, args.image, args.aspect, args.size)
with open(path, "wb") as f:
f.write(data)
return os.path.abspath(path)
failures = 0
with ThreadPoolExecutor(max_workers=count) as pool:
for path, result in zip(paths, pool.map(lambda p: _safe(task, p), paths)):
ok, value = result
if ok:
print(f"圖片已儲存至 {value}")
else:
failures += 1
print(f"第 {os.path.basename(path)} 張生成失敗:{value}", file=sys.stderr)
if failures == count:
sys.exit("全部生成失敗。")
def _safe(fn, arg):
try:
return True, fn(arg)
except Exception as e: # noqa: BLE001 — 單張失敗不影響其它併發任務
return False, str(e)
if __name__ == "__main__":
main()