●ARGON — gemini-4-argon is generally available as of 10/13, aimed at complex coding, agents, math and science●DEEP RES — deep-research-pro-preview-12-2025 shuts down 10/23, 6 days left. Move to deep-research-preview-04-2026●NANO — gemini-nano-banana-2.1 is GA (10/06). gemini-3.1-flash-image is deprecated with no shutdown date yet●MCP — One write-up cut an 85,000-token tool result to 310 tokens by wrapping it in an ADK FunctionTool●FLASH — gemini-3.7-flash and 3.5-flash now route automatically to newer Flash models (10/08)●NEW — Stop Re-Reading Every Crash Report: Gre…●ARGON — gemini-4-argon is generally available as of 10/13, aimed at complex coding, agents, math and science●DEEP RES — deep-research-pro-preview-12-2025 shuts down 10/23, 6 days left. Move to deep-research-preview-04-2026●NANO — gemini-nano-banana-2.1 is GA (10/06). gemini-3.1-flash-image is deprecated with no shutdown date yet●MCP — One write-up cut an 85,000-token tool result to 310 tokens by wrapping it in an ADK FunctionTool●FLASH — gemini-3.7-flash and 3.5-flash now route automatically to newer Flash models (10/08)●NEW — Stop Re-Reading Every Crash Report: Gre…
Rebuilding My Wallpaper App's Color Variations on Nano Banana 2 — Life After the Imagen 4 Shutdown, and Why Resolution Decides the Margin
How I moved my wallpaper app's color-variation pipeline onto gemini-3.1-flash-image (Nano Banana 2): where Imagen 4 users have to go now, the current Interactions API call shape, a two-stage design derived from published per-resolution pricing, and a script that finds retired model IDs still sitting in your code.
One night in September I was reading the deprecations page top to bottom, and I stopped at the Imagen section. The row for imagen-4.0-generate-001 was greyed out. Shutdown date: August 17, 2026 — more than a month before the day I was looking at it.
The pipeline itself hadn't aged. Handing a model a base image and asking for a color variation is the same idea it always was. What had aged was the answer to a single question: who do you hand it to?
This is the record of moving that pipeline onto the current models. I checked every model ID, call shape and price against the official pages on September 20, 2026. What I'd say first is that the real work wasn't swapping the call — it was that the unit economics underneath it had changed.
Three models can return an image today
Reading the deprecations page (last updated 2026-09-17 UTC) alongside the pricing page, the current image models come down to three. Every Imagen model is shut down, and the listed replacement is gemini-3.1-flash-image.
Model ID
Known as
Where it fits
Standard image output
Shutdown
gemini-3.1-flash-image
Nano Banana 2
The workhorse. Up to 4K, reliable text rendering
1K $0.067 / 2K $0.101 / 4K $0.151
None announced
gemini-3.1-flash-lite-image
Nano Banana 2 Lite
Fastest and cheapest, 1K-oriented, built for volume
1K $0.0336
None announced
gemini-3-pro-image
Nano Banana Pro
Hard compositions and strict instruction following
1K/2K $0.134 / 4K $0.24
None announced
gemini-2.5-flash-image
Nano Banana (legacy)
The original. Migrate off it
$0.039 per image
2026-10-02
imagen-4.0-generate-001 and siblings
Imagen 4
Retired
—
2026-08-17 (already down)
One thing in that table will bite you if you copy it straight across. The gemini-2.5-flash-image row lists its recommended replacement as gemini-3.1-flash-image-preview — and that preview is itself marked shut down on June 25, 2026, a few sections higher on the same page. The warning on the pricing page says something different and correct: move to Gemini 3.1 Flash Image or Gemini 3.1 Flash Lite Image. I now cross-check the "recommended replacement" column against the shutdown dates on the same page before I copy anything out of it. Follow the table literally and you migrate toward an endpoint that is already off.
The Imagen 4 shutdown was announced in the June 15, 2026 release notes and carried out on August 17. I used to run two tracks — Imagen 4 for seed images, a Gemini image model for user-facing derivatives. Both tracks live on Nano Banana now. What I lost by collapsing them wasn't quality; it was a branch in my prompt builder that existed only to phrase the same request two ways.
The call shape moved to the Interactions API
The current image generation guide shows Interactions API samples rather than generate_content. You no longer walk candidates[0].content.parts looking for inline data — there is one place to read, interaction.output_image. Base64 in, base64 out.
import base64import osfrom google import genaiclient = genai.Client(api_key=os.environ["GEMINI_API_KEY"])MODEL_PREVIEW = "gemini-3.1-flash-lite-image" # candidates: cheap and fastMODEL_FINAL = "gemini-3.1-flash-image" # the adopted one, at delivery resolutiondef encode(path: str) -> str: with open(path, "rb") as f: return base64.b64encode(f.read()).decode("utf-8")def generate_preview(base_image_path: str, instruction: str): """Send the base image plus one instruction, get one candidate back.""" interaction = client.interactions.create( model=MODEL_PREVIEW, input=[ {"type": "text", "text": instruction}, { "type": "image", "data": encode(base_image_path), "mime_type": "image/png", }, ], response_format={ "type": "image", "mime_type": "image/png", "aspect_ratio": "9:16", # wallpapers are tall "image_size": "1K", # 1K is plenty for a candidate }, ) image = interaction.output_image if image is None: return None, interaction.id return base64.b64decode(image.data), interaction.id
For the one candidate the user actually picks, previous_interaction_id continues the same exchange. Being able to say "same picture, higher resolution" without rebuilding the prompt is what makes the two-stage design practical.
def upscale_adopted(previous_id: str, image_size: str = "2K"): """Redraw the chosen candidate at delivery resolution.""" interaction = client.interactions.create( model=MODEL_FINAL, input=( "Redraw this at delivery resolution, keeping the composition and " "color exactly as they are. Do not change the subject, hue or brightness." ), previous_interaction_id=previous_id, response_format={ "type": "image", "mime_type": "image/png", "aspect_ratio": "9:16", "image_size": image_size, }, ) image = interaction.output_image return base64.b64decode(image.data) if image else None
Something else left my implementation along the way. temperature, top_p and top_k were deprecated on July 21, 2026. No shutdown date has been announced, but I can no longer design around dialing variance with a parameter.
Diversity now comes from the prompt side, written out as explicit branches. Three candidates means three instructions.
VARIATION_PROMPTS = { "color": [ "Keep the subject and composition; shift to a warm, reddish sunset palette.", "Keep the subject and composition; shift to a cool, blue-toned dusk palette.", "Keep the subject and composition; shift to a pink magic-hour palette.", ], "time": [ "Keep the subject; move the scene to moonlit night.", "Keep the subject; move the scene to the half-light before dawn.", "Keep the subject; move the scene to hard midday sun.", ],}
When I was leaning on sampling, I sent one instruction three times and hoped the results would differ. Looking back, I had turned a hope into a spec. Writing three instructions also tells me exactly which line to fix when one of them comes back wrong.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A single table that reconciles Nano Banana 2, 2 Lite, Pro and the now-retired Imagen 4 models against the official deprecation and pricing pages
✦A working variation pipeline written in the current Interactions API shape, including previous_interaction_id for the high-resolution pass
✦A two-stage design derived from published per-resolution prices that cuts the monthly API bill by 33% without changing what the user sees
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Here is what runs the moment someone taps "see color variations" on a wallpaper detail screen. I list the failure handling next to each stage, because that is where monitoring eventually goes.
#
Runs on
What happens
On failure
1
Client
Send base image ID and variation type (color / time / mood)
Plain retry
2
Backend
Verify App Attest / Play Integrity, check entitlement
Fail closed with 403
3
Backend
Cache lookup on base image ID + type + prompt index
On a hit, skip the rest
4
Backend
Three parallel requests to Lite, one per instruction
Top up missing candidates, twice at most
5
Backend
Acceptance check with pHash and hue distance
Show fewer candidates rather than near-duplicates
6
Client
Display the candidates; the user picks one
If nobody picks, it ends here — nothing more is billed
7
Backend
Redraw only the chosen one at delivery resolution, push to CDN
Fall back to serving the 1K candidate
Every generation request goes through the backend. Calling the API straight from the client breaks down on both key management and usage control, and I won't bake a key into an app I give away for free.
Splitting stages 6 and 7 is what the whole cost argument rests on. You don't pay delivery-resolution prices for pictures nobody chose — that's the entire trick.
Resolution decides the margin
This is where the migration actually hurt. The pricing footnotes are explicit: image output is billed as tokens, with a published equivalent per resolution. On Nano Banana 2 Standard, 1K is 1,120 tokens at $0.067, 2K is 1,680 tokens at $0.101, and 4K is 2,520 tokens at $0.151.
Wallpapers are tall, and delivery starts at 2K. So for this app the per-image price is set by resolution, not by which model I picked. Here's what the obvious approach costs, using my own usage (median 18 uses per Premium user per month, three candidates per use).
Approach
Per use
Per month (18 uses)
After a 33% cache hit rate
Everything on Nano Banana 2 at 2K
$0.3030
$5.45
$3.65
Everything on Nano Banana 2 at 4K
$0.4530
$8.15
$5.46
Candidates on Lite 1K, adopted one on Nano Banana 2 at 2K
$0.2018
$3.63
$2.43
Candidates on Lite 1K, adopted one on Nano Banana 2 at 4K
$0.2518
$4.53
$3.04
My app's Premium tier is ¥580 a month, roughly $3.90 at ¥150 to the dollar. Read the first row again: generating everything at 2K spends $3.65 of that on the API even after caching. The margin is pennies. The second row, 4K for everyone, costs more than the subscription brings in.
The two-stage split lands the same experience at $2.43 — 33% below the flat-2K row, and 44% below flat 4K. Running those numbers is what told me the migration wasn't really about the call signature.
Anything that doesn't have to happen right now can go to Batch, which halves it again: Nano Banana 2 Batch bills image output at $30 per 1M tokens, so 1K comes to $0.034 an image. Seasonal seed images go to Batch; anything a user is waiting on goes to Standard.
Candidates cheap, the chosen one expensive. That line I intend to keep even after the next price revision.
Machine acceptance for what comes back — pHash and hue
Even with three distinct instructions, one candidate in three still comes back looking almost like the base image. At full size the difference is visible; at thumbnail size it slips past whoever is reviewing. The user sees the candidates side by side, so one near-duplicate reads as a broken feature.
So an acceptance check runs right after generation. A perceptual hash drops candidates that are too close to the base, and candidates that are too close to each other.
from io import BytesIOfrom PIL import Imageimport imagehash# thresholds picked after measuring the distance distribution on my own catalogMIN_DIST_FROM_BASE = 8 # below this it's effectively the base imageMAX_DIST_FROM_BASE = 26 # above this it's a different pictureMIN_DIST_BETWEEN = 6 # minimum distance between accepted candidatesdef perceptual_hash(image_bytes: bytes) -> imagehash.ImageHash: image = Image.open(BytesIO(image_bytes)).convert("RGB") # normalize first so a different output resolution doesn't move the hash return imagehash.phash(image.resize((256, 256)))def accept_variations(base_bytes: bytes, candidates: list[bytes]) -> list[bytes]: base_hash = perceptual_hash(base_bytes) accepted: list[tuple[imagehash.ImageHash, bytes]] = [] for candidate in candidates: candidate_hash = perceptual_hash(candidate) distance = candidate_hash - base_hash if distance < MIN_DIST_FROM_BASE or distance > MAX_DIST_FROM_BASE: continue # too identical, or drifted into a different picture if any(candidate_hash - accepted_hash < MIN_DIST_BETWEEN for accepted_hash, _ in accepted): continue # too close to one already accepted accepted.append((candidate_hash, candidate)) return [image_bytes for _, image_bytes in accepted]
On the thresholds: imagehash.phash defaults to 64 bits, so Hamming distance runs 0 to 64. Measuring pairs from my own catalog that a person would call "a color variation", most landed between 8 and 24. The failures where the subject's outline had changed usually came in above 26. Those two measurements set the bounds — and since the numbers move with your material, I'd run the distribution on your own catalog once before adopting them.
Note that this runs at the candidate stage, so rejected images never cost delivery-resolution money. Putting acceptance before or after the expensive stage changes the invoice, not just the quality.
One honest limitation: pHash is built on a DCT of the grayscale image, so it is blunt about variations that only move hue. On the color axis I pair it with the HSV H channel.
import numpy as npdef mean_hue(image_bytes: bytes) -> float: hsv = Image.open(BytesIO(image_bytes)).convert("HSV").resize((128, 128)) return float(np.asarray(hsv)[:, :, 0].mean())def hue_shift(base_bytes: bytes, candidate_bytes: bytes) -> float: """Difference in mean H. Used to verify the color axis (0-255 scale).""" return abs(mean_hue(candidate_bytes) - mean_hue(base_bytes))
When the type is color, candidates with a hue_shift under 12 get dropped as well. A pure hue change barely moves the pHash distance, and without that one line those candidates sail through.
Caching, which trims the bill further
Different users ask for the same variation of the same base image more often than you'd expect. When a season turns, the same picture and the same color shift cluster into a narrow window.
My implementation caches candidates in Cloudflare R2, keyed on base image ID, type and prompt index. Hit rate sits around 33% — that's the number behind the "after cache" column above.
Cache everything forever and storage starts costing more than generation, so anything untouched for 60 days is deleted, handled by an R2 lifecycle policy I no longer think about.
The adopted high-resolution image is treated differently. Candidates are disposable, but the one a user chose and saved gets asked for again later, sometimes on a new device. Candidates for 60 days, adopted images indefinitely — that one distinction changed the kind of email I get.
Finding retired model IDs still sitting in your code
What bothers me about the Imagen 4 shutdown is how I found out: by re-reading a page, not by anything breaking. To stop leaving that to chance, I put a script in CI that scans the repository for model IDs and checks them against my own copy of the deprecation table.
The table comes first. A null means no announced shutdown; a date means the endpoint goes away. I keep this hand-maintained on purpose — it's the one place I want to have read with my own eyes.
Then the scanner. It collects anything that looks like a model ID and reports what is retired, what is about to be, and what isn't in the table at all.
#!/usr/bin/env python3"""List the model IDs left in your code and check them against the deprecation table. python3 model_id_audit.py <directory>Exit code 1 means something retired, expiring, or unknown is still referenced."""import jsonimport reimport sysfrom collections import defaultdictfrom datetime import datefrom pathlib import PathTABLE_PATH = Path(__file__).with_name("model_shutdown_table.json")MODEL_RE = re.compile(r"\b(?:gemini|imagen|veo|lyria)-[0-9][0-9A-Za-z.\-]*\b")SKIP_DIRS = {".git", "node_modules", "dist", "build", "__pycache__", ".venv"}SUFFIXES = {".py", ".ts", ".tsx", ".js", ".jsx", ".swift", ".kt", ".json", ".yaml", ".yml", ".toml"}SELF_FILES = {Path(__file__).name, TABLE_PATH.name}def load_table() -> dict: raw = json.loads(TABLE_PATH.read_text()) return {k: (date.fromisoformat(v) if v else None) for k, v in raw.items()}def scan(root: Path) -> dict: found = defaultdict(list) for path in root.rglob("*"): if not path.is_file() or path.suffix not in SUFFIXES: continue if SKIP_DIRS & set(path.parts) or path.name in SELF_FILES: continue for lineno, line in enumerate(path.read_text(errors="ignore").splitlines(), 1): for model_id in MODEL_RE.findall(line): found[model_id].append(f"{path.relative_to(root)}:{lineno}") return founddef verdict(model_id: str, table: dict, today: date) -> str: if model_id not in table: return "not in table (check it yourself)" shutdown = table[model_id] if shutdown is None: return "current" if shutdown <= today: return f"retired ({shutdown})" return f"expiring ({shutdown}, {(shutdown - today).days} days left)"def main() -> int: root = Path(sys.argv[1] if len(sys.argv) > 1 else ".") table, today, found = load_table(), date.today(), scan(root) if not found: print("no model IDs found") return 0 width = max(len(k) for k in found) bad = 0 for model_id in sorted(found): state = verdict(model_id, table, today) if state != "current": bad += 1 mark = " " if state == "current" else "!!" print(f"{mark} {model_id.ljust(width)} {len(found[model_id]):>2} hits {state}") if state != "current": for hit in found[model_id][:3]: print(f" - {hit}") print(f"\nscanned {root.resolve()} / {bad} to fix") return 1 if bad else 0if __name__ == "__main__": sys.exit(main())
Run against a small tree standing in for my pre-migration code — MODEL = "gemini-3.1-flash-image", LEGACY_MODEL = "gemini-2.5-flash-image", SEED_MODEL = "imagen-4.0-generate-001", plus a gemini-3.1-flash-image-preview left in a config file — this is what came back on September 20, 2026.
The third line is the one I was grateful for. That preview ID lived in a fallback field, which means nothing calls it on a normal day — the tests stay green precisely because it is never exercised. It would have been reached exactly once: on the day the primary path failed in production.
I kept the "not in table" verdict too, because I want to notice unfamiliar IDs — a typo and a line I transcribed wrong show up in the same column.
I count model IDs as dated inventory, not as configuration. Inventory is something you check the remaining days on.
Three traps I hit in production
Nothing runs the way the design says it will. Three places where I tripped.
Trap 1: all three candidates come back identical
Before I split the instructions, I sent one prompt three times and expected variety. I got near-copies. With sampling parameters deprecated, there is no going back to that approach anyway.
Over-specific prompts cause the same thing. Ask only for "a sunset palette" and the model reaches for the sunset it already has. Naming the hue first — reddish, blue-toned, pink — separates them reliably.
Trap 2: the output comes back empty
On dark wallpapers, and on ones where a figure is faintly visible, you sometimes get text and no image. For a while I had no idea why: no exception, just two candidates where three were expected.
Now the first thing I check is whether interaction.output_image is None, and if it is, the request falls through to a different instruction. I left the safety filters where they are. This is an app children may open, and I would rather not argue about it during store review.
Trap 3: the high-resolution redraw isn't the same picture
You pass previous_interaction_id meaning "same thing, bigger", and the composition shifts slightly. The user chose a specific picture, so delivering a different one is simply a failure.
Two guards. The instruction says explicitly not to change subject, hue or brightness. And the redraw is compared against the candidate with the same perceptual_hash; if the distance exceeds 6, the 1K candidate ships instead. Lower resolution, but the picture they picked.
Who I'd recommend this to
This fits if your app already has an image catalog and you want to return derivatives in response to something the user does. Wallpapers aren't special here — plated variations in a recipe app, color options for the same product, seasonal treatments of cover art all take the same shape.
It fits badly if every image is generated from scratch as the main event. There, the candidate stage buys you little, and a single Nano Banana Pro generation gives steadier results. The two-stage split exists so you don't pay premium prices for pictures nobody picked; with no picking, there is nothing to split.
If you want one concrete next step, run model_id_audit.py against your own repository before you research anything else. Counting what you currently point at is faster than looking up where to go. That is where I should have started.
Thank you for reading. If it settles the order in which you check your own dated values, that's enough.
Share
Thank You for Reading
Gemini Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.