●CLI — While the Gemini API release notes have sat still since September 3, the CLI moved: v0.59.0 is now stable, and the changes are mostly about security●SSRF — Server-side request forgery in MCP OAuth metadata discovery has been closed off. If you connect third-party MCP servers, this one is for you●RESTRICTED — Restricted mode now enforces fail-closed workspace trust and filters MCP servers. Expect different behaviour if you run unattended with MCP attached●PINNING — Explicitly versioned Flash model IDs were not being preserved. If you pin versions for reproducibility, this quietly affected you●SEP 30 — Seventeen days until gemini-omni-flash-preview is retired, and gemini-2.5-flash-image follows on October 2●MIGRATION — The official table still points to gemini-3.1-flash-image-preview, which was retired on June 25. The real destination is the GA gemini-3.1-flash-image●CLI — While the Gemini API release notes have sat still since September 3, the CLI moved: v0.59.0 is now stable, and the changes are mostly about security●SSRF — Server-side request forgery in MCP OAuth metadata discovery has been closed off. If you connect third-party MCP servers, this one is for you●RESTRICTED — Restricted mode now enforces fail-closed workspace trust and filters MCP servers. Expect different behaviour if you run unattended with MCP attached●PINNING — Explicitly versioned Flash model IDs were not being preserved. If you pin versions for reproducibility, this quietly affected you●SEP 30 — Seventeen days until gemini-omni-flash-preview is retired, and gemini-2.5-flash-image follows on October 2●MIGRATION — The official table still points to gemini-3.1-flash-image-preview, which was retired on June 25. The real destination is the GA gemini-3.1-flash-image
Putting Gemini image generation to work: from prompt design to thumbnails generated from video
A practical playbook for running Gemini image generation as a repeatable workflow instead of a lucky dip. From decomposing prompts into reproducible parts to the video-to-image automation unlocked by the Nano Banana 2 GA, with working code, a pre-publish quality gate, and a design that survives preview shutdowns.
Putting Gemini image generation to work: from prompt design to thumbnails generated from video
The biggest reason image generation eats your time is that you can't reproduce a good result after the fact — you can't tell whether the one frame that came out was good or bad, or why. Generating OGP images for the four Dolice Labs blogs (Gemini Lab among them) and promo assets for the wallpaper apps I've run as an indie developer, I kept hitting exactly that: I'd land a great image and never be able to produce the same quality again.
This article is a practical note on running image generation as a recorded, reproducible workflow rather than a one-shot gamble. The first half breaks a prompt into reproducible units; the second half moves into the "generate a single image from a video" capability that became generally available in June 2026 with Nano Banana 2 (gemini-3.1-flash-image), wiring it into indie automation. For context: my contemporary art is hand-made and uses no generative AI. I use generative AI only on the "delivery" side — asset generation for the app business and the blogs. Holding that line up front makes the judgment calls below easier to read.
A prompt is a four-part design, not a dice roll
Prompts that produce good images reliably are built from structure, not feel. Keeping records, I found the parts that actually move the result collapse to four.
Subject (what to draw), scene/environment (where and when), style (photographic, watercolor, 3D, and so on), and detail (palette, lighting, angle, emotion). Filling these four deliberately turns a vague instruction like "a person sitting" into something reproducible: "a woman in her 30s seated on a sofa by a window in the evening, lit by warm interior light, professional photographic style."
The practical payoff of separating the parts is that when you land a winner, you can isolate which part did the work. Swap only the scene and regenerate, and you compare atmosphere while holding subject and style fixed. Rewrite the whole prompt every time and you lose that comparison.
Keep a "shape" for each use case
Rather than starting from scratch each time, keep a minimal shape per use case. The skeletons I reuse across blogs and apps look like this.
For a blog hero image: "[subject that symbolizes the article] doing [task/situation]. [approachable palette]. Slightly soft photographic or flat-illustration style. 16:9 landscape." For social posts, state square (1:1) or vertical (9:16) explicitly and add one note of magazine-cover polish. For presentation assets, raise the abstraction — "3D CGI symbolizing [concept], blue and purple neon, forward-looking mood" — making the concept, not a person, the lead.
Having shapes makes the automation in the second half easier, because you can templatize the brief and just slot in the article title or the video's content to produce assets.
✦
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
✦Decompose prompts into four reproducible parts (subject, scene, style, detail) so you can reproduce a winning result from a log instead of luck
✦Implement the video-to-thumbnail automation unlocked by the Nano Banana 2 (gemini-3.1-flash-image) GA, with working Python you can run today
✦Take home a lightweight gate that rejects broken images before publishing, plus a model-ID design that won't break when preview models shut down on 6/25
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.
A classic trap that lowers reproducibility is the negative instruction. Image models are poor at "do not draw X" and often draw it anyway. "A doctor smiling warmly at a patient" is more stable than "a doctor not holding a syringe." Write the desired state in the positive.
The other trap is contradiction. "A night scene but with bright daytime light" breeds broken outputs. Just keeping time of day, light source, and palette consistent visibly narrows the swing between hits and misses on regeneration.
The same prompt still varies — record to reproduce
This is where a gamble diverges from a repeatable workflow. The same prompt still produces varying results, which is exactly why recording prompt, seed, model, and timestamp together pays off when a good frame appears.
For each asset run I drop a one-line log: the brief, the model ID used, and the filename I shipped. That alone lets me reproduce, three months later, a request I make of myself like "same tone as that article's hero image." Generation without records is starting from zero every time.
Everything so far works in the app's UI or a browser-based ImageFX. From here we put the design onto the API and generate assets without human hands. A June 2026 update widened that doorway.
Generating a single image from a video — what the Nano Banana 2 (gemini-3.1-flash-image) GA changed
In June 2026, Nano Banana 2 (gemini-3.1-flash-image) and Nano Banana Pro (gemini-3-pro-image) reached GA. For indie asset generation the biggest piece is this: you can pass a video file as multimodal context and generate a thumbnail, poster, or infographic informed by its content (video-to-image is a gemini-3.1-flash-image-class use).
Previously there was a manual step of hunting for a good frame, cutting it out, and editing it. Passing the video directly lets you start from "turn the climax of this video into one vertical thumbnail." For indie developers handling stand.fm audio or short-form clips, it's a quietly meaningful shift.
The implementation is the same google-genai SDK flow as ordinary image output, with a video part added to the input. Small videos can go inline.
import osfrom google import genaifrom google.genai import typesclient = genai.Client(api_key=os.environ["GEMINI_API_KEY"])# Nano Banana 2 = gemini-3.1-flash-image (GA, 2026-06).# This family is what accepts a video as context.IMAGE_MODEL = "gemini-3.1-flash-image"def thumbnail_from_video(video_path: str, brief: str) -> bytes: with open(video_path, "rb") as f: video_bytes = f.read() response = client.models.generate_content( model=IMAGE_MODEL, contents=[ types.Part.from_bytes(data=video_bytes, mime_type="video/mp4"), brief, ], config=types.GenerateContentConfig( response_modalities=["TEXT", "IMAGE"], ), ) for part in response.candidates[0].content.parts: if part.inline_data is not None: return part.inline_data.data raise RuntimeError("No image part returned (text-only response)")
The brief is just the "shape" from the first half. For a vertical thumbnail: "Turn the climax of this video into one image. Subject large and centered, vertical 9:16, high-contrast for legibility, no text" — the four parts plus aspect ratio. I want to place the title text reliably downstream, so telling the image model not to add text keeps the pipeline stable.
Videos beyond a few tens of MB are safer through the Files API than inline. Swap the inline bytes for an uploaded file reference in contents; the call's skeleton is unchanged.
def thumbnail_from_large_video(video_path: str, brief: str) -> bytes: uploaded = client.files.upload(file=video_path) response = client.models.generate_content( model=IMAGE_MODEL, contents=[uploaded, brief], config=types.GenerateContentConfig( response_modalities=["TEXT", "IMAGE"], ), ) for part in response.candidates[0].content.parts: if part.inline_data is not None: return part.inline_data.data raise RuntimeError("No image returned")
Even with IMAGE in response_modalities, responses come back in three mixes: text only, image only, or both. Looping over parts and treating something as an image only when inline_data is present is mandatory in automation. Skip that guard and a text-only response throws.
Passing the whole video is expensive — narrow it to the seconds that matter
The first wall I hit running video-to-image for real wasn't quality. It was the bill and the wait. Video input accrues tokens by duration, so handing over a three-minute file means paying for the entire clip in order to get the dozen seconds that actually shape the thumbnail.
While producing thumbnails for short videos, I missed this at first and lived with roughly forty seconds of wall-clock per image — a queue that stretched further with every added asset. Once I started trimming to the window around the peak moment before sending, the same quality came back in single-digit seconds, and token consumption dropped by an order of magnitude.
How it's sent
Input duration
Wall-clock per image
What I observed
Original video, sent inline
~3 min
35–45 s
Large assets force the Files API, adding steps
Trimmed to the peak window
12 s
6–9 s
Stays inline; roughly a tenth of the tokens
Trimming is one ffmpeg call. Audio is never used for image generation, so drop it — and lower the frame rate and resolution while you're there. A thumbnail needs composition and color, not smooth motion.
import osimport subprocessimport tempfiledef clip_window(video_path: str, start_sec: float, dur_sec: float = 12.0) -> str: """Cut the window around the peak so the video you send is small.""" out = os.path.join(tempfile.mkdtemp(), "window.mp4") subprocess.run( [ "ffmpeg", "-y", "-ss", str(start_sec), # placed before -i for a fast seek "-t", str(dur_sec), "-i", video_path, "-an", # audio is unused for image generation "-vf", "fps=2,scale=-2:720", # lower frame rate and resolution too out, ], check=True, capture_output=True, ) return out
That leaves the question of which second counts as the peak. I didn't want to watch every file, so I made it two passes. First I send the same video to a text-response model and ask it to answer with nothing but a number: at what second would you freeze this for a thumbnail? Then that second goes into clip_window(), and only the short window reaches image generation.
Adding a call to save a call looks like a bad trade until you weigh them. The text response is dramatically cheaper, and cutting the video that reaches image generation to a tenth outweighs it by a wide margin. Ask something cheap to aim, then fire the expensive thing once. That ordering has become my default shape for multimodal automation well beyond images.
A lightweight gate that rejects broken images before publishing
The scary part of automation is a broken frame flowing straight to publication. I did exactly this in wallpaper color-variant generation and shipped a flat, single-color image. Before adding heavy quality scoring, stopping obvious accidents with a cheap mechanical check is the realistic move.
The gate below decides, with no extra API call, whether the aspect ratio drifted from target and whether the image is nearly single-color (a generation failure).
from PIL import Imagefrom io import BytesIOdef passes_quality_gate(img_bytes: bytes, target_ratio: float = 16 / 9, tol: float = 0.08) -> bool: img = Image.open(BytesIO(img_bytes)).convert("RGB") w, h = img.size ratio = w / h if abs(ratio - target_ratio) / target_ratio > tol: return False # aspect ratio drifted too far from target # Detect near-single-color (flat / broken): inspect pixel variance after shrinking small = img.resize((32, 18)) px = list(small.getdata()) n = len(px) mean = [sum(c[i] for c in px) / n for i in range(3)] var = sum(sum((c[i] - mean[i]) ** 2 for i in range(3)) for c in px) / n return var > 150 # empirical threshold; below it, flat/broken images dominate
The 150 threshold is just my local empirical value. It needs tuning to your material, but even using it only as a "regenerate an obviously bad frame once" retry condition noticeably dropped my manual rework rate at volume. Rather than aiming for perfect quality judgment from day one, putting a cheap gate that stops only accidents first is the better cost-benefit trade for indie work.
Make your winners machine-readable — a generation ledger and series consistency
Earlier I said to record the good frame when it appears. But a note written for human eyes doesn't connect to automation. A record becomes an asset only once a program can look it up. I append one line of JSON per generation. No database, and keeping it in the repository means the history shows up in diffs.
import hashlibimport jsonimport timefrom pathlib import PathLEDGER = Path("generated/ledger.jsonl")def record(brief: str, model: str, out_path: Path, adopted: bool) -> str: """Append one generation as one line. `key` identifies identical conditions.""" key = hashlib.sha1(f"{model}\n{brief}".encode("utf-8")).hexdigest()[:12] LEDGER.parent.mkdir(parents=True, exist_ok=True) with LEDGER.open("a", encoding="utf-8") as f: f.write(json.dumps({ "key": key, "model": model, "brief": brief, "file": str(out_path), "adopted": adopted, "at": time.strftime("%Y-%m-%dT%H:%M:%S"), }, ensure_ascii=False) + "\n") return keydef find_adopted(keyword: str) -> list[dict]: """Look up only adopted generations, by a word in the brief.""" if not LEDGER.exists(): return [] rows = [json.loads(line) for line in LEDGER.read_text(encoding="utf-8").splitlines() if line] return [r for r in rows if r["adopted"] and keyword in r["brief"]]
The adopted boolean is what makes this useful. Being able to retrieve the frame you chose, rather than everything you produced, is the whole point. My own ledger sits near a twenty percent adoption rate; the other eighty percent stays recorded too, so I don't pay for the same miss twice.
Records earn their keep again when a series has to hold together. When I want the OGP images across four Lab sites to feel like one set, writing the palette and spacing in words still drifts from run to run. Handing back an already-adopted image as a reference turned out to be far more reliable than describing it.
def generate_in_series(reference_path: str, brief: str) -> bytes: """Anchor on an adopted frame to produce the next one in the same air.""" with open(reference_path, "rb") as f: reference = f.read() response = client.models.generate_content( model=IMAGE_MODEL, contents=[ types.Part.from_bytes(data=reference, mime_type="image/png"), "Keep this image's palette, texture, and use of negative space. " "Produce one new image with the following content.\n" + brief, ], config=types.GenerateContentConfig( response_modalities=["TEXT", "IMAGE"], ), ) for part in response.candidates[0].content.parts: if part.inline_data is not None: return part.inline_data.data raise RuntimeError("No image returned")
Pull the reference with find_adopted(), pass it to generate_in_series(). Once those two mesh, a vague request like "the way that other one felt" becomes an executable step.
There is a side effect worth watching: anchor too hard and everything starts to look like the same picture. I keep the reference's influence to palette and texture, and vary subject and angle in the brief every time. Consistency isn't about making things match — it's about making them read as one body of work when placed side by side. That framing makes it much easier to decide how much to pin down.
Don't get caught by preview shutdowns — keep model IDs in one place
One more thing that always pays off in automation: deprecation handling. Preview models shut down on notice. In fact gemini-3.1-flash-image-preview and gemini-3-pro-image-preview are scheduled to stop on 6/25, and any code that hard-coded them will fail en masse with INVALID_ARGUMENT the moment they go.
The defense is simple: don't scatter model IDs through the code — confine them to one dictionary. When something shuts down, you follow with a one-line change instead of grepping the whole codebase.
# Keep model IDs in one place so a preview shutdown is a one-line follow-up.IMAGE_MODELS = { "fast": "gemini-3.1-flash-image", # Nano Banana 2 (GA) "quality": "gemini-3-pro-image", # Nano Banana Pro (GA)}def resolve_image_model(tier: str = "fast") -> str: try: return IMAGE_MODELS[tier] except KeyError as e: # Don't silently fall back an unknown tier to a default; fail loudly raise ValueError(f"Unknown image model tier: {tier}") from e
I reuse the same generation base across several Lab sites and the wallpaper apps, so I register preview shutdown dates on an operations calendar and finish moving to GA versions the week before. Making time-bound deprecations something you "can't miss" is the best insurance when one person runs several projects.
Your next step
Start by taking one prompt that produced a winner and decomposing it into the four parts — subject, scene, style, detail — as a template. Feed that template as the brief to thumbnail_from_video() and you can run the minimal loop for generating assets from a video today. For the move after that, add one call to record() and start the ledger — it costs a line now and pays out three months from now. With records and shapes in hand, image generation turns from a gamble into a process you can reproduce. Thank you for reading.
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.