8月17日の午前に、壁紙用の生成スクリプトを流しました。引数の書き換えは前日までに済ませてあったので、あとは走らせるだけのつもりでいました。
止まったのは保存の行です。
AttributeError: 'GenerateContentResponse' object has no attribute 'generated_images'引数の対応表ばかり眺めていて、返ってくる側が別の形になることを最後まで手当てしていませんでした。移行作業の重心は入口にあると思い込んでいたのですが、実際に手が止まったのは出口でした。
同じところで足を止めている方に向けて、出口側で分かれる3つの分岐を書き出しておきます。
保存まで引き受けてくれていたのは、旧 API のほうでした
以前の呼び出しは、画像オブジェクトが save() を持っていました。ファイルに落とすところまで SDK の担当だったわけです。
# 以前: 保存まで SDK 側にありました
result = client.models.generate_images(
model="imagen-4.0-generate-001",
prompt=prompt,
config=types.GenerateImagesConfig(number_of_images=4),
)
for i, gen in enumerate(result.generated_images):
gen.image.save(f"out_{i}.png")移行後は、テキスト生成と同じ経路を通ります。返ってくるのは候補(candidate)の列で、その中に part が並びます。画像はその part のひとつとして、inline_data にぶら下がって届きます。
# 移行後: 返るのは part の列で、保存は呼び出し側の責任になります
response = client.models.generate_content(
model="gemini-3.1-flash-image",
contents=prompt,
config=types.GenerateContentConfig(
response_modalities=["TEXT", "IMAGE"],
),
)ここで response.candidates[0].content.parts[0] を画像だと決め打ちすると、後で必ず刺さります。モデルは短い説明文を先に返してくることがあり、その場合の 0 番目はテキストの part です。
part を順に見て、inline_data を持つものだけを拾う。これが出口側の最初の一歩になります。
def image_blobs(response):
"""画像の part だけを取り出します。無ければ空リストを返します。"""
blobs = []
for candidate in response.candidates or []:
content = getattr(candidate, "content", None)
if content is None:
continue
for part in content.parts or []:
blob = getattr(part, "inline_data", None)
if blob is not None:
blobs.append(blob)
return blobscontent を getattr で受けているのは、安全側の判定で止まったとき候補そのものは返るのに中身が空になる場合があるからです。ここを素直にドットでたどると、本来の失敗理由が AttributeError に化けて見えなくなります。
分岐1 — 画像の part が1件も返らないとき
一番静かに壊れるのがこれでした。
例外は出ません。ループが0回まわって終わり、保存されたファイルは0件、スクリプトの終了コードは 0 です。私自身、最初の1回はログを見るまで成功したと思っていました。
0件だったときは、その場で理由を添えて落とすようにしています。
blobs = image_blobs(response)
if not blobs:
feedback = getattr(response, "prompt_feedback", None)
reasons = [c.finish_reason for c in (response.candidates or [])]
raise RuntimeError(
"画像 part が0件です "
f"/ block_reason={getattr(feedback, 'block_reason', None)} "
f"/ finish_reason={reasons}"
)block_reason はプロンプト側で止まった場合に入り、finish_reason は生成の途中で打ち切られた場合の理由が入ります。どちらを見るかで手当てが変わるので、両方を同じ行に出しておくと後追いが速くなります。
プロンプト側で止まっていれば文言の見直し、生成側で止まっていればリトライや別モデルへの退避、という分け方です。
分岐2 — 枚数が揺れるとき
旧 API では number_of_images を渡していたので、返る枚数は呼び出し側で確定していました。移行後は枚数の指示もプロンプトに寄るため、4枚のつもりが3枚で返ることがあります。
これが個人開発の実務でどう効くかというと、後段でずれます。
壁紙は端末の解像度ごとに束で作っていて、生成した順番と後続のカテゴリ分類の行が1対1で対応しています。束のうち1枚が欠けたまま次へ渡すと、分類結果が1行ずつずれて別の画像に貼り付きます。落ちてくれるならまだ良いほうで、静かにずれたまま App Store 用の素材まで進んでしまうと、気づくのは目視の段になります。
期待枚数との突き合わせを、保存の前に置きました。
EXPECTED = 4
if len(blobs) != EXPECTED:
raise RuntimeError(f"期待 {EXPECTED} 枚に対して {len(blobs)} 枚でした")たった3行ですが、この3行があるかどうかで後段の信頼度がまるで変わります。枚数を固定できない設計であれば、少なくとも実際の件数をログに残し、後続処理をインデックスではなくファイル名で結び直しておくほうが安全です。
分岐3 — 拡張子は mime_type から決めます
.png の決め打ちも移行時に見落としがちなところです。返ってくる mime_type は必ずしも image/png とは限りません。
| mime_type | 付ける拡張子 | 備考 |
|---|---|---|
| image/png | .png | 透過が要る素材はこちら |
| image/jpeg | .jpg | 写真寄りの出力で返ることがあります |
| image/webp | .webp | そのまま配信に載せる場合の候補 |
標準ライブラリの mimetypes.guess_extension() に任せたくなりますが、image/jpeg に対する戻り値は Python のバージョンによって揺れた経緯があります。素材の命名規則が実行環境で変わるのは避けたいので、私はこの対応表を明示的に持つ側を選びました。
EXT_BY_MIME = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/webp": ".webp",
}もうひとつ、inline_data.data はすでにデコード済みの bytes です。REST の生レスポンスを見たことがあると base64 の文字列を想像しますが、SDK を通した時点で復号は済んでいます。
with open(path, "wb") as f:
f.write(blob.data) # base64 のデコードは不要ですここに base64.b64decode() を足すと、運が良ければ例外、運が悪ければ壊れたファイルが黙って出来上がります。移行のついでに保存処理を触るときは、この一行だけ先に確認しておくと安心です。
3つの分岐をまとめた受け取り関数
上の3つを1か所に寄せると、こうなります。既存のスクリプトの保存部分を、この関数の呼び出しに置き換える形で使えます。
from pathlib import Path
EXT_BY_MIME = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/webp": ".webp",
}
def save_images(response, out_dir: Path, stem: str, expected: int) -> list[Path]:
"""response から画像を取り出して保存し、書き出したパスを返します。"""
blobs = []
for candidate in response.candidates or []:
content = getattr(candidate, "content", None)
if content is None:
continue
for part in content.parts or []:
blob = getattr(part, "inline_data", None)
if blob is not None:
blobs.append(blob)
if not blobs:
feedback = getattr(response, "prompt_feedback", None)
reasons = [c.finish_reason for c in (response.candidates or [])]
raise RuntimeError(
"画像 part が0件です "
f"/ block_reason={getattr(feedback, 'block_reason', None)} "
f"/ finish_reason={reasons}"
)
if len(blobs) != expected:
raise RuntimeError(f"期待 {expected} 枚に対して {len(blobs)} 枚でした")
out_dir.mkdir(parents=True, exist_ok=True)
saved = []
for i, blob in enumerate(blobs):
mime = blob.mime_type or ""
if mime not in EXT_BY_MIME:
raise RuntimeError(f"未知の mime_type です: {mime!r}")
path = out_dir / f"{stem}_{i}{EXT_BY_MIME[mime]}"
path.write_bytes(blob.data)
saved.append(path)
return saved呼び出し側はこれだけです。
paths = save_images(
response,
out_dir=Path("build/wallpapers"),
stem="sunrise",
expected=4,
)
print(f"{len(paths)} 枚保存しました")expected を引数にしているのは、枚数の期待値を関数の外に出しておきたかったからです。素材の種類によって束の大きさが違うので、呼び出し側が決めるほうが自然でした。
なお、引数側の対応関係についてはgenerate_images から generate_content へ書き換えるとき、引数は素直に移りませんにまとめてあります。入口と出口を続けて直すなら、そちらを先に読むほうが順番としては早いです。
今日のうちに足しておきたい1行
3つの分岐のうち、いま1つだけ足すなら枚数の突き合わせです。
0件と mime_type は、いずれ例外なり見た目なりで気づきます。枚数の欠けだけは、後段まで静かに進んでしまいます。既存のスクリプトを開いて、保存ループの直前に len(blobs) を確認する行を1つ入れてみてください。
まだ移行前で、そもそもどのコードが停止対象を呼んでいるか掴めていない場合は、8月17日の停止対象を自分のコードから洗い出すの棚卸し手順から始めると迷いが減ります。
配布済みのアプリ側にモデル名が焼き込まれていて、停止日をまたいだ切り戻しを押してよいか迷っている場合は、判断の立て方を配布済みアプリに残るモデル名と、停止日をまたいだ切り戻しの設計で扱っています。
移行の最後の一歩で止まっている方の助けになれば嬉しいです。お読みいただきありがとうございました。