
Nano Banana
- 179 installs
- 339 repo stars
- Updated August 4, 2026
- glebis/claude-skills
Wire Claude Code to Nano Banana image generation APIs for rapid asset creation, edits, and variant batches inside agent workflows without leaving the IDE.
About
nano-banana equips Claude Code to call Nano Banana-style image models from agent sessions, standardizing prompts, sizes, and retries so teams can produce marketing art, UI placeholders, and iteration sets without a separate design toolchain.
- Nano Banana image API integration
- Prompt and parameter templates for agents
- Batch or variant generation helpers
- Error handling for model quotas and timeouts
- Outputs usable in docs, decks, or UI mocks
Nano Banana by the numbers
- 179 all-time installs (skills.sh)
- Ranked #645 of 1,335 Generative Media skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/glebis/claude-skills --skill nano-bananaAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 179 |
|---|---|
| repo stars | ★ 339 |
| Last updated | August 4, 2026 |
| Repository | glebis/claude-skills ↗ |
What it does
Wire Claude Code to Nano Banana image generation APIs for rapid asset creation, edits, and variant batches inside agent workflows without leaving the IDE.
Files
Nano Banana - Gemini Image Generation
Generate and edit images from text prompts via Google's Gemini image generation API.
When to Use
- User requests image generation, creation, or production from a text description
- Editing existing images with text instructions
- Style-transfer: generate new images that match the aesthetic of a reference
- Creating illustrations for presentations, articles, thumbnails, social posts
- Batch variations of the same concept
First-Time Setup
scripts/nano_banana.py initWizard checks dependencies (sops, age, magick), verifies the API key, and saves defaults to ~/.config/nano-banana/config.yaml.
Quick Start
# Simple generation
scripts/nano_banana.py "a minimalist illustration of a rocket" ./rocket.png
# With style preset
scripts/nano_banana.py --preset editorial "interconnected nodes" ./nodes.png
# YouTube thumbnail (auto-cropped to 1280x720)
scripts/nano_banana.py --preset grain --platform youtube "coffee on desk" ./thumb.png
# Generate 4 variants + contact sheet
scripts/nano_banana.py --preset wireframe "a crystal" ./crystal.png --n 4
# Edit existing image
scripts/nano_banana.py --edit ./old.png "make the background deep teal" ./new.png
# Style reference (match aesthetic of existing image)
scripts/nano_banana.py --reference ./style.png "a new mountain landscape" ./mountain.png
# Re-roll last prompt
scripts/nano_banana.py again
# View history
scripts/nano_banana.py history -n 10Requirements
GEMINI_API_KEY— auto-decrypted fromsecrets.enc.yamlvia SOPS + age. Fallback:export GEMINI_API_KEY=...sops,age— for key decryptionmagick(ImageMagick) — for platform fit + contact sheetspython3withpyyaml
Models
| Model | Alias | Nano Banana Name | Use When |
|---|---|---|---|
gemini-3.1-flash-image-preview (default) | flash | Nano Banana 2 | Best instruction following, fast |
gemini-3-pro-image-preview | pro | Nano Banana Pro | Highest quality, text in images |
gemini-2.5-flash-image | flash-2.5 | Nano Banana (original) | Legacy |
Use via --model flash|pro|flash-2.5 or full ID.
Style Presets
scripts/nano_banana.py list-presets
scripts/nano_banana.py --preset editorial "your subject" out.png| Preset | Style |
|---|---|
editorial | Thin lines on black, muted palette, technical diagram feel |
blueprint | White/cyan lines on dark navy, engineering drawing |
ink | Japanese sumi-e ink wash, organic brushstrokes, monochrome |
risograph | Flat colors, grain, terracotta + sage, zine aesthetic |
wireframe | 3D wireframe mesh, glowing edges on black |
constellation | Star map dots connected by faint lines, celestial |
brutalist | Bold shapes, thick borders, hard shadows, flat colors |
grain | Film grain photo, high ISO, warm cinematic tones |
Defined in presets.yaml — edit to add your own.
Platform Presets
scripts/nano_banana.py list-platforms
scripts/nano_banana.py --platform youtube "your subject" out.pngGenerated image is automatically resized + center-cropped to target dimensions.
| Platform | Size |
|---|---|
youtube | 1280×720 |
youtube-short | 1080×1920 |
slides | 1920×1080 |
blog | 1200×630 |
x | 1600×900 |
square | 1080×1080 |
story | 1080×1920 |
pinterest | 1000×1500 |
Features
Variants + Contact Sheet
--n N generates N variants in parallel and assembles them into a contact sheet:
scripts/nano_banana.py --preset ink "mountain" ./mt.png --n 6
# Creates mt-01.png ... mt-06.png + mt-contact.pngBatch generation (campaigns / multi-copy ad sets)
When each output needs different text or a different prompt (e.g. a set of ads sharing one style), loop over a list. --n won't help here — it re-rolls the same prompt. Use a shared $STYLE string + a per-item array.
cd /abs/output/dir
REF="_reference.png" # optional style anchor
STYLE="<shared visual-style description, written once>"
# Each entry: pipe-delimited fields + output filename. NAME the array — see gotchas.
ADS=(
"CALL NOW|FOR TOTAL|CONFIDENTIALITY|ad-callnow.png"
"REDACT|BEFORE|YOU SEND|ad-redact.png"
)
for entry in "${ADS[@]}"; do
IFS='|' read -r L1 L2 L3 OUT <<< "$entry"
python3 scripts/nano_banana.py \
"$STYLE The exact text reads, on three centered lines: '$L1' / '$L2' / '$L3'. Spell every word correctly." \
"$OUT" --reference "$REF" --platform youtube --model pro --no-metadata --project NAME
doneShell gotchas (this is zsh on macOS — these bite every time):
- Never name a loop array `LINES`, `COLUMNS`, `PATH`, `path`, `status`, `argv`, etc. — zsh reserves them.
LINES=(...)fails withcan't assign array value to non-array special. UseADS,ITEMS,JOBS. - zsh does NOT word-split unquoted variables (unlike bash).
CMD="magick montage"; $CMD ...looks for a single command literally named "magick montage". Don't stuff multi-word commands in a var — call the command directly, or use an array (cmd=(magick montage); "${cmd[@]}"). - Always quote expansions —
"$OUT","${ADS[@]}"— paths and prompts contain spaces. - Use `'single quotes'` for the exact text you want rendered, inside the double-quoted prompt, so the model reproduces it verbatim.
- To parallelize a batch, append
&per iteration andwaitat the end — but cap concurrency (the API rate-limits); sequential is safest for >6 items.
After a batch, assemble a review sheet by calling the tool directly (no var indirection): magick montage ad-*.png -tile 2x3 -geometry 480x270+6+6 -background black _contact.png.
Edit Mode
Pass an existing image and the prompt becomes the edit instruction:
scripts/nano_banana.py --edit ./thumb.png "remove the watermark, warmer colors" ./clean.pngReference Images (Style Anchor)
Use one or more reference images to guide the aesthetic without editing them:
scripts/nano_banana.py --reference ./episode1.png --reference ./episode2.png \
"episode 3: data drift" ./ep3.pngProjects + Metadata
Organize outputs by project:
scripts/nano_banana.py --project lab-04/meeting-02 --preset editorial "MCP loops" ./overlay.png
# Saves to ~/nano-banana/outputs/lab-04/meeting-02/20260414-<subject>.png + .json sidecarRe-roll + History
scripts/nano_banana.py again # rerun last prompt
scripts/nano_banana.py history -n 20 # show last 20 generations
scripts/nano_banana.py history --project lab-04Dry Run
Preview the composed prompt without calling the API:
scripts/nano_banana.py --preset editorial --platform youtube "subject" --dry-runTransient Errors & Retry
The API occasionally returns 500/INTERNAL or empty candidates. The script retries up to 4 times with exponential backoff (2s, 4s, 8s, 16s). Permanent errors (4xx, safety violations) fail fast without retry.
Prompt Tips
- Specify visual style: "photograph", "flat illustration", "watercolor", "3D render"
- Include composition: "centered", "white background", "wide shot"
- Name colors: "blue and white color scheme", "warm earth tones"
- For text rendering, use
--model proand quote exact text:'with the text "Hello"'
See references/api_reference.md for full API documentation.
Files
scripts/nano_banana.py— main CLI (Python)scripts/generate_image.sh— thin bash wrapper (back-compat)presets.yaml— style presetsplatforms.yaml— platform sizing presetssecrets.enc.yaml— encrypted API key (SOPS + age)~/.config/nano-banana/config.yaml— user defaults (frominit)~/.config/nano-banana/history.jsonl— generation log~/.config/nano-banana/last.json— last run (foragain)
{
"name": "nano-banana",
"description": "Generate and edit images using Google's Gemini image generation models (Nano Banana family). Supports style presets, pla",
"author": {
"name": "Gleb Kalinin"
},
"repository": "https://github.com/glebis/claude-skills",
"license": "MIT"
}# Platform-specific output presets
# Each platform sets target dimensions; image is resized/cropped to fit.
# Usage: generate_image.sh --platform youtube "subject" out.png
youtube:
description: "YouTube thumbnail"
width: 1280
height: 720
youtube-short:
description: "YouTube Shorts cover (vertical)"
width: 1080
height: 1920
slides:
description: "Presentation slide (16:9 HD)"
width: 1920
height: 1080
blog:
description: "Blog hero / social preview (1.91:1)"
width: 1200
height: 630
x:
description: "X / Twitter in-stream image"
width: 1600
height: 900
square:
description: "Instagram / LinkedIn square post"
width: 1080
height: 1080
story:
description: "Instagram / TikTok story (9:16)"
width: 1080
height: 1920
pinterest:
description: "Pinterest pin (2:3)"
width: 1000
height: 1500
# Nano Banana Image Generation Presets
# Usage: generate_image.sh --preset <name> "subject description" output.png
#
# Each preset defines a style wrapper around the user's subject.
# The subject gets interpolated into the prompt template at {subject}.
editorial:
description: "Dark editorial style — thin lines on black, muted palette, technical feel"
prompt: "on pure black background, {subject}, thin pale gray and subtle orange line art, minimalist technical diagram aesthetic, clean vector style, sparse with lots of negative space, dark moody, no text, no labels"
blueprint:
description: "Technical blueprint — white/cyan lines on dark navy, engineering drawing feel"
prompt: "technical blueprint drawing of {subject}, thin white and light cyan lines on dark navy #0a1628 background, engineering schematic style, precise geometric, grid dots in background, architectural, no text, clean"
ink:
description: "Japanese ink wash — organic brushstrokes, minimal, high contrast"
prompt: "{subject}, Japanese ink wash sumi-e style, black ink on off-white rice paper, minimal brushstrokes, negative space, organic flowing lines, zen aesthetic, monochrome, no text"
risograph:
description: "Risograph print — flat colors, grain, slight misregistration, zine aesthetic"
prompt: "{subject}, risograph print style, flat color fills with visible halftone grain, terracotta orange and sage green ink on cream paper, slight color misregistration, screen print texture, vintage zine aesthetic, no text"
wireframe:
description: "3D wireframe — glowing edges on black, holographic feel"
prompt: "{subject}, 3D wireframe mesh rendering on pure black background, thin glowing white and orange edge lines, transparent polygonal structure, holographic feel, no solid fills, technical visualization, no text"
constellation:
description: "Star map — dots connected by faint lines, celestial feel"
prompt: "{subject} depicted as a constellation pattern, small white dots connected by very faint thin lines on deep black sky background, scattered star dust, celestial map aesthetic, minimal, ethereal, no text"
brutalist:
description: "Neobrutalist — bold shapes, black borders, hard shadows, flat saturated colors"
prompt: "{subject}, neobrutalist graphic design style, bold geometric shapes, thick 3px black outlines, hard offset shadows, flat saturated colors orange and yellow on white, no gradients, no rounded corners, poster art, no text"
grain:
description: "Film grain photo — moody, high ISO, warm tones, cinematic"
prompt: "photograph of {subject}, heavy film grain ISO 3200, warm color grading, shallow depth of field, cinematic lighting, moody atmosphere, 35mm analog film look, slight vignette"
Gemini Image Generation API Reference
Endpoint
POST https://generativelanguage.googleapis.com/v1beta/models/{MODEL}:generateContentAuthentication
Pass the API key via header:
x-goog-api-key: YOUR_GEMINI_API_KEYModels
| Model | Speed | Quality | Text Rendering | Best For |
|---|---|---|---|---|
gemini-2.5-flash-image | Fast | Good | Basic | Iteration, bulk generation |
gemini-3-pro-image-preview | Slower | Excellent | Accurate | Final assets, text-heavy images |
Request Format
Text-to-Image (Generation)
{
"contents": [{
"parts": [{"text": "prompt describing the image"}]
}],
"generationConfig": {
"responseModalities": ["TEXT", "IMAGE"]
}
}Image Editing (with Input Image)
{
"contents": [{
"parts": [
{"text": "edit instruction"},
{
"inlineData": {
"mimeType": "image/png",
"data": "BASE64_ENCODED_IMAGE"
}
}
]
}],
"generationConfig": {
"responseModalities": ["TEXT", "IMAGE"]
}
}Response Format
{
"candidates": [{
"content": {
"parts": [
{"text": "Description of the generated image"},
{
"inlineData": {
"mimeType": "image/png",
"data": "BASE64_ENCODED_IMAGE_DATA"
}
}
]
}
}]
}The response may contain:
- Text parts describing what was generated
- Image parts with base64-encoded PNG data
- Both text and image parts in a single response
Prompt Engineering Tips
Style Control Keywords
- Photography: "photograph", "DSLR", "35mm film", "studio lighting", "natural light"
- Illustration: "flat illustration", "vector art", "line drawing", "watercolor"
- Design: "minimalist", "isometric", "3D render", "UI mockup", "infographic"
- Art styles: "oil painting", "sketch", "pixel art", "comic book style"
Composition Keywords
- "centered composition", "rule of thirds", "wide shot", "close-up"
- "white background", "transparent background", "gradient background"
- "top-down view", "bird's eye view", "eye level", "low angle"
Quality Boosters
- "high quality", "detailed", "sharp focus", "professional"
- "4K", "high resolution", "crisp"
- Specify color palette: "blue and white color scheme", "warm earth tones"
Text in Images
For images containing text (diagrams, logos, labels):
- Use
gemini-3-pro-image-previewfor accurate text rendering - Specify exact text in quotes: 'with the text "Hello World"'
- Specify font style: "bold sans-serif text", "handwritten text"
Error Codes
| HTTP Code | Meaning | Fix |
|---|---|---|
| 400 | Bad request / invalid model | Check model name |
| 401 | Invalid API key | Verify GEMINI_API_KEY |
| 429 | Rate limited | Wait and retry |
| 500 | Server error | Retry after a moment |
Rate Limits
- Free tier: ~15 requests per minute
- Paid tier: Higher limits based on plan
- Each image generation counts as one request
#!/bin/bash
# Nano Banana - thin bash wrapper for nano_banana.py
#
# This script preserves the original positional interface for backwards compat:
# ./generate_image.sh "prompt" [output] [model]
# ./generate_image.sh --preset NAME "subject" [output]
# ./generate_image.sh --list-presets
#
# For full features (variants, edit, platforms, projects, history, init), use:
# ./nano_banana.py
#
# See: nano_banana.py --help
set -eo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PY="${SCRIPT_DIR}/nano_banana.py"
# Backwards-compatible legacy flag: --list-presets → list-presets subcommand
if [ "${1:-}" = "--list-presets" ]; then
exec python3 "$PY" list-presets
fi
# Legacy: third positional was model ID. Translate to --model.
# Usage was: ./generate_image.sh [--preset N] "prompt" [output] [model]
ARGS=()
POSITIONAL=()
MODEL_FROM_POS=""
while [ $# -gt 0 ]; do
case "$1" in
--preset|--platform|--model|--edit|--reference|--project|--n)
ARGS+=("$1" "$2")
shift 2
;;
--list-presets|--list-platforms|init|again|history|list-presets|list-platforms|\
--no-metadata|--dry-run|--help|-h)
ARGS+=("$1")
shift
;;
*)
POSITIONAL+=("$1")
shift
;;
esac
done
# Map 3rd positional (legacy model) to --model if not already set
if [ "${#POSITIONAL[@]}" -ge 3 ]; then
MODEL_FROM_POS="${POSITIONAL[2]}"
POSITIONAL=("${POSITIONAL[0]}" "${POSITIONAL[1]}")
ARGS+=("--model" "$MODEL_FROM_POS")
fi
exec python3 "$PY" "${ARGS[@]}" "${POSITIONAL[@]}"
#!/usr/bin/env python3
"""Nano Banana - Gemini Image Generation Tool
A CLI wrapper around Google's Gemini image generation models (Nano Banana family).
Supports style presets, platform-specific sizing, variants, image editing, and
organized output with metadata.
Usage:
nano_banana.py [flags] "prompt" [output.png]
nano_banana.py init # onboarding wizard
nano_banana.py again # regenerate last
nano_banana.py history [-n 10] # show history
nano_banana.py list-presets
nano_banana.py list-platforms
"""
from __future__ import annotations
import argparse
import base64
import concurrent.futures
import json
import os
import re
import shutil
import subprocess
import sys
import time
import urllib.error
import urllib.request
from dataclasses import asdict, dataclass
from datetime import datetime
from pathlib import Path
from typing import Any
try:
import yaml
except ImportError:
print("Error: PyYAML not installed. Run: pip3 install pyyaml", file=sys.stderr)
sys.exit(1)
SKILL_DIR = Path(__file__).resolve().parent.parent
PRESETS_FILE = SKILL_DIR / "presets.yaml"
PLATFORMS_FILE = SKILL_DIR / "platforms.yaml"
CENTRAL_SECRETS = SKILL_DIR.parent / "secrets.enc.yaml"
LOCAL_SECRETS = SKILL_DIR / "secrets.enc.yaml"
CONFIG_DIR = Path.home() / ".config" / "nano-banana"
CONFIG_FILE = CONFIG_DIR / "config.yaml"
HISTORY_FILE = CONFIG_DIR / "history.jsonl"
LAST_RUN_FILE = CONFIG_DIR / "last.json"
DEFAULT_MODEL = "gemini-3.1-flash-image-preview"
MODELS = {
"flash": "gemini-3.1-flash-image-preview",
"pro": "gemini-3-pro-image-preview",
"flash-2.5": "gemini-2.5-flash-image",
}
API_URL = "https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent"
# ---------- Config & secrets ----------
def load_config() -> dict[str, Any]:
if CONFIG_FILE.exists():
with CONFIG_FILE.open() as f:
return yaml.safe_load(f) or {}
return {}
def get_api_key() -> str | None:
if os.environ.get("GEMINI_API_KEY"):
return os.environ["GEMINI_API_KEY"]
if shutil.which("sops") is None:
return None
for secrets_path in (LOCAL_SECRETS, CENTRAL_SECRETS):
if secrets_path.exists():
try:
result = subprocess.run(
["sops", "--decrypt", "--extract", '["GEMINI_API_KEY"]', str(secrets_path)],
capture_output=True,
text=True,
check=True,
)
key = result.stdout.strip()
if key:
return key
except subprocess.CalledProcessError:
continue
return None
def load_presets() -> dict[str, dict[str, str]]:
if not PRESETS_FILE.exists():
return {}
with PRESETS_FILE.open() as f:
return yaml.safe_load(f) or {}
def load_platforms() -> dict[str, dict[str, Any]]:
if not PLATFORMS_FILE.exists():
return {}
with PLATFORMS_FILE.open() as f:
return yaml.safe_load(f) or {}
# ---------- API calls ----------
@dataclass
class GenerationResult:
output_path: Path
prompt: str
model: str
preset: str | None
platform: str | None
edit_source: str | None
timestamp: str
duration_s: float
def _image_part(path: Path) -> dict[str, Any]:
with path.open("rb") as f:
b64 = base64.b64encode(f.read()).decode()
mime = "image/png" if path.suffix.lower() == ".png" else "image/jpeg"
return {"inlineData": {"mimeType": mime, "data": b64}}
# HTTP status codes that are worth retrying (transient)
_RETRYABLE_STATUS = {429, 500, 502, 503, 504}
_MAX_RETRIES = 4
_BASE_BACKOFF_S = 2.0
class TransientAPIError(RuntimeError):
"""Retryable API failure."""
class PermanentAPIError(RuntimeError):
"""Non-retryable API failure (4xx, content safety, etc.)."""
def _call_gemini_once(
prompt: str,
model: str,
api_key: str,
edit_source: Path | None,
reference_images: list[Path] | None,
) -> bytes:
parts: list[dict[str, Any]] = [{"text": prompt}]
if edit_source:
parts.append(_image_part(edit_source))
for ref in reference_images or []:
parts.append(_image_part(ref))
payload = {
"contents": [{"parts": parts}],
"generationConfig": {"responseModalities": ["TEXT", "IMAGE"]},
}
req = urllib.request.Request(
API_URL.format(model=model),
data=json.dumps(payload).encode(),
headers={
"Content-Type": "application/json",
"x-goog-api-key": api_key,
},
)
try:
with urllib.request.urlopen(req, timeout=120) as resp:
data = json.loads(resp.read())
except urllib.error.HTTPError as e:
body = e.read().decode(errors="replace")
if e.code in _RETRYABLE_STATUS:
raise TransientAPIError(f"HTTP {e.code}: {body[:400]}") from e
raise PermanentAPIError(f"HTTP {e.code}: {body[:400]}") from e
except urllib.error.URLError as e:
raise TransientAPIError(f"Network error: {e}") from e
candidates = data.get("candidates", [])
if not candidates:
# Body may contain promptFeedback about safety blocks
feedback = data.get("promptFeedback", {})
raise PermanentAPIError(f"No candidates. Feedback: {feedback}")
candidate = candidates[0]
# Check finishReason for transient vs permanent issues
finish_reason = candidate.get("finishReason", "")
for part in candidate.get("content", {}).get("parts", []):
if "inlineData" in part:
return base64.b64decode(part["inlineData"]["data"])
# No image returned
text_parts = [p.get("text", "") for p in candidate.get("content", {}).get("parts", [])]
msg = f"No image. finishReason={finish_reason}. Text: {' '.join(text_parts)[:300]}"
# Treat "other" / "internal" / empty as transient; SAFETY / RECITATION as permanent
if finish_reason in {"SAFETY", "RECITATION", "PROHIBITED_CONTENT"}:
raise PermanentAPIError(msg)
raise TransientAPIError(msg)
def _call_gemini(
prompt: str,
model: str,
api_key: str,
edit_source: Path | None = None,
reference_images: list[Path] | None = None,
) -> bytes:
"""Call Gemini image API with retry on transient failures."""
last_err: Exception | None = None
for attempt in range(_MAX_RETRIES):
try:
return _call_gemini_once(prompt, model, api_key, edit_source, reference_images)
except TransientAPIError as e:
last_err = e
if attempt == _MAX_RETRIES - 1:
break
delay = _BASE_BACKOFF_S * (2 ** attempt)
print(
f" ⚠ transient error (attempt {attempt + 1}/{_MAX_RETRIES}): {str(e)[:120]}\n"
f" retrying in {delay:.0f}s...",
file=sys.stderr,
)
time.sleep(delay)
except PermanentAPIError:
raise # no point retrying
raise RuntimeError(f"Gave up after {_MAX_RETRIES} retries. Last error: {last_err}")
# ---------- Post-processing ----------
def apply_platform_fit(
image_path: Path, platform: dict[str, Any], output_path: Path | None = None
) -> Path:
"""Resize/crop image to platform target dimensions using ImageMagick."""
width = platform["width"]
height = platform["height"]
dest = output_path or image_path
cmd = [
"magick",
str(image_path),
"-resize",
f"{width}x{height}^",
"-gravity",
"center",
"-extent",
f"{width}x{height}",
str(dest),
]
subprocess.run(cmd, check=True, capture_output=True)
return dest
def make_contact_sheet(image_paths: list[Path], output_path: Path, cols: int = 2) -> Path:
"""Assemble contact sheet using ImageMagick montage."""
if not image_paths:
raise ValueError("No images for contact sheet")
cmd = [
"magick",
"montage",
*[str(p) for p in image_paths],
"-tile",
f"{cols}x",
"-geometry",
"600x+10+10",
"-background",
"#0f0f0f",
str(output_path),
]
subprocess.run(cmd, check=True, capture_output=True)
return output_path
# ---------- Metadata ----------
def slugify(text: str, max_len: int = 40) -> str:
slug = re.sub(r"[^a-zA-Z0-9]+", "-", text.lower()).strip("-")
return slug[:max_len] or "image"
def resolve_output_path(
output_arg: str | None,
subject: str,
project: str | None,
config: dict[str, Any],
) -> Path:
if output_arg:
return Path(output_arg).expanduser().resolve()
base = Path(config.get("output_dir", "~/nano-banana/outputs")).expanduser()
if project:
base = base / project
base.mkdir(parents=True, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
return base / f"{ts}-{slugify(subject)}.png"
def write_sidecar(image_path: Path, metadata: dict[str, Any]) -> Path:
sidecar = image_path.with_suffix(".json")
with sidecar.open("w") as f:
json.dump(metadata, f, indent=2, default=str)
return sidecar
def append_history(entry: dict[str, Any]) -> None:
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
with HISTORY_FILE.open("a") as f:
f.write(json.dumps(entry, default=str) + "\n")
def save_last_run(entry: dict[str, Any]) -> None:
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
with LAST_RUN_FILE.open("w") as f:
json.dump(entry, f, indent=2, default=str)
def load_last_run() -> dict[str, Any] | None:
if not LAST_RUN_FILE.exists():
return None
with LAST_RUN_FILE.open() as f:
return json.load(f)
# ---------- Core generation ----------
def compose_prompt(subject: str, preset_name: str | None, presets: dict) -> str:
if not preset_name:
return subject
if preset_name not in presets:
raise SystemExit(
f"Error: preset '{preset_name}' not found. Available: {list(presets.keys())}"
)
template = presets[preset_name]["prompt"]
return template.replace("{subject}", subject)
def generate_once(
prompt: str,
output_path: Path,
model: str,
api_key: str,
edit_source: Path | None = None,
reference_images: list[Path] | None = None,
platform: dict[str, Any] | None = None,
) -> GenerationResult:
start = time.time()
img_bytes = _call_gemini(prompt, model, api_key, edit_source, reference_images)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_bytes(img_bytes)
if platform:
apply_platform_fit(output_path, platform)
return GenerationResult(
output_path=output_path,
prompt=prompt,
model=model,
preset=None,
platform=None,
edit_source=str(edit_source) if edit_source else None,
timestamp=datetime.now().isoformat(),
duration_s=round(time.time() - start, 2),
)
def generate(args: argparse.Namespace, config: dict[str, Any]) -> None:
api_key = get_api_key()
if not api_key:
print(
"Error: GEMINI_API_KEY not set and could not decrypt from secrets.enc.yaml.\n"
"Run: nano_banana.py init",
file=sys.stderr,
)
sys.exit(1)
presets = load_presets()
platforms = load_platforms()
edit_source = Path(args.edit).expanduser().resolve() if args.edit else None
if edit_source and not edit_source.exists():
sys.exit(f"Error: edit source not found: {edit_source}")
reference_paths: list[Path] = []
for ref in args.reference or []:
p = Path(ref).expanduser().resolve()
if not p.exists():
sys.exit(f"Error: reference image not found: {p}")
reference_paths.append(p)
# Subject / prompt composition
if edit_source:
if not args.prompt:
sys.exit("Error: edit mode requires a prompt (the instruction)")
prompt = args.prompt
preset_name = None
else:
if not args.prompt:
sys.exit("Error: prompt is required")
preset_name = args.preset or config.get("default_preset")
prompt = compose_prompt(args.prompt, preset_name, presets)
platform_conf = None
platform_name = args.platform or config.get("default_platform")
if platform_name:
if platform_name not in platforms:
sys.exit(
f"Error: platform '{platform_name}' not found. Available: {list(platforms.keys())}"
)
platform_conf = platforms[platform_name]
model = args.model or config.get("default_model", DEFAULT_MODEL)
# Resolve model aliases
model = MODELS.get(model, model)
if args.dry_run:
print(f"Model: {model}")
print(f"Preset: {preset_name or '(none)'}")
print(f"Platform: {platform_name or '(none)'}")
if edit_source:
print(f"Edit source: {edit_source}")
if reference_paths:
print(f"References: {', '.join(str(r) for r in reference_paths)}")
print(f"Prompt:\n {prompt}")
return
# Resolve output path(s)
project = args.project or config.get("default_project")
primary_output = resolve_output_path(args.output, args.prompt, project, config)
n = max(1, args.n)
outputs: list[GenerationResult] = []
def _job(i: int) -> GenerationResult | None:
if n == 1:
out = primary_output
else:
out = primary_output.with_name(f"{primary_output.stem}-{i+1:02d}{primary_output.suffix}")
print(f"[{i+1}/{n}] Generating → {out.name}")
try:
result = generate_once(
prompt, out, model, api_key, edit_source, reference_paths, platform_conf
)
except Exception as e:
print(f" ✗ variant {i+1} failed: {e}", file=sys.stderr)
return None
result.preset = preset_name
result.platform = platform_name
return result
if n == 1:
one = _job(0)
if one is None:
sys.exit(1)
outputs.append(one)
else:
with concurrent.futures.ThreadPoolExecutor(max_workers=min(n, 4)) as pool:
outputs = [r for r in pool.map(_job, range(n)) if r is not None]
if not outputs:
sys.exit("All variants failed.")
# Contact sheet for variants
if n > 1:
contact_path = primary_output.with_name(f"{primary_output.stem}-contact.png")
make_contact_sheet([o.output_path for o in outputs], contact_path)
print(f"Contact sheet: {contact_path}")
# Metadata
for result in outputs:
entry = asdict(result)
entry["output_path"] = str(result.output_path)
if not args.no_metadata:
write_sidecar(result.output_path, entry)
append_history({**entry, "subject": args.prompt, "project": project})
# Save last run for --again
save_last_run({
"subject": args.prompt,
"prompt": prompt,
"preset": preset_name,
"platform": platform_name,
"model": model,
"edit_source": str(edit_source) if edit_source else None,
"reference": [str(r) for r in reference_paths],
"project": project,
"n": n,
"output": str(primary_output),
})
for result in outputs:
print(f"✓ {result.output_path} ({result.duration_s}s)")
# ---------- Subcommands ----------
def cmd_list_presets(_args: argparse.Namespace) -> None:
presets = load_presets()
for name, conf in presets.items():
print(f" {name:16s} {conf.get('description', '')}")
def cmd_list_platforms(_args: argparse.Namespace) -> None:
platforms = load_platforms()
for name, conf in platforms.items():
desc = conf.get("description", "")
size = f"{conf.get('width', '?')}x{conf.get('height', '?')}"
print(f" {name:16s} {size:12s} {desc}")
def cmd_again(args: argparse.Namespace) -> None:
last = load_last_run()
if not last:
sys.exit("Error: no previous run found. Generate an image first.")
# Reconstruct args
ns = argparse.Namespace(
prompt=last["subject"],
output=None, # force new output path
preset=last.get("preset"),
platform=last.get("platform"),
model=last.get("model"),
edit=last.get("edit_source"),
reference=last.get("reference", []),
project=last.get("project"),
n=args.n if args.n else last.get("n", 1),
no_metadata=False,
dry_run=False,
)
print(f"↻ Regenerating: {last['subject']}")
generate(ns, load_config())
def cmd_history(args: argparse.Namespace) -> None:
if not HISTORY_FILE.exists():
print("No history yet.")
return
with HISTORY_FILE.open() as f:
lines = f.readlines()
entries = [json.loads(ln) for ln in lines]
if args.project:
entries = [e for e in entries if e.get("project") == args.project]
entries = entries[-args.n :]
for e in entries:
ts = e.get("timestamp", "?")[:19].replace("T", " ")
preset = e.get("preset") or "-"
subject = e.get("subject", "")[:60]
print(f"{ts} {preset:14s} {subject}")
print(f" → {e.get('output_path', '?')}")
def cmd_init(_args: argparse.Namespace) -> None:
print("Nano Banana — Onboarding\n")
# Check deps
deps = {
"sops": "brew install sops",
"age": "brew install age",
"magick": "brew install imagemagick",
}
missing = [d for d in deps if shutil.which(d) is None]
if missing:
print("Missing dependencies:")
for d in missing:
print(f" {d} — install with: {deps[d]}")
print()
else:
print("✓ Dependencies (sops, age, magick) installed\n")
# Check API key
key = get_api_key()
if key:
print(f"✓ GEMINI_API_KEY accessible (via SOPS or env)")
else:
print("✗ GEMINI_API_KEY not found.")
print(" Set it by editing secrets.enc.yaml with: sops secrets.enc.yaml")
print(" Or: export GEMINI_API_KEY=AIza... in your shell rc")
print()
# Config wizard
config = load_config()
def ask(key: str, prompt: str, default: str = "") -> str:
current = config.get(key, default)
shown = f" [{current}]" if current else ""
val = input(f"{prompt}{shown}: ").strip()
return val or current
config["default_model"] = ask(
"default_model",
"Default model (flash / pro / flash-2.5)",
"flash",
)
config["default_platform"] = ask(
"default_platform",
"Default platform (youtube / slides / blog / square / none)",
"",
)
config["default_project"] = ask(
"default_project",
"Default project name (optional, e.g. 'blog')",
"",
)
out_dir = ask(
"output_dir",
"Default output directory",
"~/nano-banana/outputs",
)
config["output_dir"] = out_dir
# Clean empty values
config = {k: v for k, v in config.items() if v}
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
with CONFIG_FILE.open("w") as f:
yaml.safe_dump(config, f, default_flow_style=False)
print(f"\n✓ Config saved to {CONFIG_FILE}")
print("\nTry: nano_banana.py --preset editorial 'a robot' out.png")
# ---------- CLI parsing ----------
SUBCOMMANDS = {
"init": cmd_init,
"again": cmd_again,
"history": cmd_history,
"list-presets": cmd_list_presets,
"list-platforms": cmd_list_platforms,
}
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="nano_banana",
description="Nano Banana - Gemini image generation with presets, variants, edit, metadata",
epilog=(
"Subcommands: init | again | history | list-presets | list-platforms\n"
"Run 'nano_banana.py <subcommand> --help' for details."
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
p.add_argument("prompt", nargs="?", help="Prompt / subject / edit instruction")
p.add_argument("output", nargs="?", help="Output path (auto-generated if omitted)")
p.add_argument("--preset", help="Style preset name (see list-presets)")
p.add_argument("--platform", help="Platform preset (see list-platforms)")
p.add_argument("--model", help="Model: flash | pro | flash-2.5 or full ID")
p.add_argument("--edit", help="Edit mode: path to source image to modify")
p.add_argument(
"--reference",
action="append",
default=[],
help="Reference image for style/aesthetic anchor (repeatable)",
)
p.add_argument("--n", type=int, default=1, help="Number of variants (default 1)")
p.add_argument("--project", help="Project name (groups outputs in subfolder)")
p.add_argument("--no-metadata", action="store_true", help="Skip sidecar JSON")
p.add_argument("--dry-run", action="store_true", help="Show composed prompt, don't call API")
return p
def build_subparser(name: str) -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog=f"nano_banana {name}")
if name == "again":
p.add_argument("--n", type=int, default=0, help="Override variant count")
elif name == "history":
p.add_argument("-n", type=int, default=20, help="Number of entries to show")
p.add_argument("--project", help="Filter by project")
return p
def main(argv: list[str] | None = None) -> int:
argv = list(sys.argv[1:] if argv is None else argv)
# Peek for subcommand as first arg
if argv and argv[0] in SUBCOMMANDS:
sub_name = argv[0]
sub_parser = build_subparser(sub_name)
sub_args = sub_parser.parse_args(argv[1:])
SUBCOMMANDS[sub_name](sub_args)
return 0
parser = build_parser()
args = parser.parse_args(argv)
config = load_config()
if not args.prompt:
parser.print_help()
return 1
try:
generate(args, config)
except KeyboardInterrupt:
print("\nInterrupted.", file=sys.stderr)
return 130
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
GEMINI_API_KEY: ENC[AES256_GCM,data:8A3cy0A11SsdrF/Fq5QyzUtSE9YAWRDQqtB72c8gOZJVBPv5h3MP,iv:9jX09RN/auKXmOCockU8HFg9yJ/k+CdpGB/bTREKv5A=,tag:zk/OTqkUVRFlKPL1LKA2+A==,type:str]
sops:
age:
- recipient: age1g3q8la6ekarm8ynw0mq7chfpprfcrykn7atrefdm7s6ukg0v5arq60kmpt
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBjdXBaT2xwbXpKZWpHZjcw
a1lkSDY4MGsvbnQza3RVRWh4eHNGUmR2NWxVCmlIRTNmUUx4TEtJblZiTDE0Q2h5
ZkUxdTJjdVBISTVXdERpWGEzY1dPc0EKLS0tIFRDa3U3R3czVHdVUi8ybENMY1Zt
Zkp3UkVGdGlrdkxYVEtXTUhiMUpVTzAK25y7TuAMJEEY522zzYiMggYDzf6qQcqc
FIGB3+YFm5v9+DuHAM26vcnt5B0wurN3edPUH+ioHfyofbN6vjQ1HQ==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-04-13T21:30:23Z"
mac: ENC[AES256_GCM,data:xD+l2J73YA2FCDISDlw6KSzXiJdbZPO2WFSaxS4yMqG8iTJunXFqfP8YbQGAKNLx2breCB1YtAljnTH4Sugw+KuslXLisRxsJihKeUdoS5B72HgLNubdx1Q7ASKUrnGRlRMebNL/gsYudzzUXCxMUzZbFLyfiQitKsmWaKRN2vg=,iv:kCdFNYTJoxKy2uP6k63pnoqPL/7Vfe8O/h8fxFn+eQM=,tag:7RCyFDHMQ/GnyeTRzl8v1g==,type:str]
unencrypted_suffix: _unencrypted
version: 3.12.2