-
Notifications
You must be signed in to change notification settings - Fork 3.8k
Add nano-banana-pro-openrouter skill #686
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
aaronpowell
merged 8 commits into
github:main
from
nblog:add-nano-banana-pro-openrouter-skill
Feb 10, 2026
Merged
Changes from 1 commit
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
97bc889
feat(skills): add nano-banana-pro-openrouter skill
nblog ef4aa0b
fix(skill): update descriptions and improve error handling in generat…
nblog 5efb732
feat(skills): add nano-banana-pro-openrouter skill with image generat…
nblog b8bbc75
fix(generate_image): improve input image handling and validate output…
nblog 79c3429
fix(generate_image): enhance image handling and output path resolutio…
nblog 55da61a
fix(SYSTEM_TEMPLATE): clarify language matching requirement for gener…
nblog b087965
fix(SKILL.md): enhance troubleshooting section with common errors and…
nblog b1e5581
Merge remote-tracking branch 'origin/main' into add-nano-banana-pro-o…
nblog File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| --- | ||
| name: nano-banana-pro-openrouter | ||
| description: Generate or edit images via OpenRouter using openai-python with the Gemini 3 Pro Image model. Use for prompt-only image generation, image edits, and multi-image compositing; supports 1K/2K/4K output, saves results to the current working directory, and prints MEDIA lines. | ||
| metadata: | ||
|
nblog marked this conversation as resolved.
|
||
| emoji: 🍌 | ||
| requires: | ||
| bins: | ||
| - uv | ||
| env: | ||
| - OPENROUTER_API_KEY | ||
| primaryEnv: OPENROUTER_API_KEY | ||
| --- | ||
|
|
||
| # Nano Banana Pro OpenRouter | ||
|
|
||
| ## Overview | ||
|
|
||
| Generate or edit images with OpenRouter using the `google/gemini-3-pro-image-preview` model and the openai-python client. Support prompt-only generation, single-image edits, and multi-image composition. Save results to the current working directory and output MEDIA lines for easy attachment. | ||
|
|
||
| ### Prompt-only generation | ||
|
nblog marked this conversation as resolved.
|
||
|
|
||
| ``` | ||
| uv run {baseDir}/scripts/generate_image.py \ | ||
| --prompt "A cinematic sunset over snow-capped mountains" \ | ||
| --filename sunset.png | ||
| ``` | ||
|
|
||
| ### Edit a single image | ||
|
|
||
| ``` | ||
| uv run {baseDir}/scripts/generate_image.py \ | ||
| --prompt "Replace the sky with a dramatic aurora" \ | ||
| --input-image input.jpg \ | ||
| --filename aurora.png | ||
| ``` | ||
|
|
||
| ### Compose multiple images | ||
|
|
||
| ``` | ||
| uv run {baseDir}/scripts/generate_image.py \ | ||
| --prompt "Combine the subjects into a single studio portrait" \ | ||
| --input-image face1.jpg \ | ||
| --input-image face2.jpg \ | ||
| --filename composite.png | ||
| ``` | ||
|
|
||
| ## Resolution | ||
|
|
||
| - Use `--resolution` with `1K`, `2K`, or `4K`. | ||
| - Default is `1K` if not specified. | ||
|
|
||
| ## System prompt customization | ||
|
|
||
| The skill reads an optional system prompt from `assets/SYSTEM_TEMPLATE`. This allows you to customize the image generation behavior without modifying code. | ||
|
|
||
| ## Behavior and constraints | ||
|
|
||
| - Read the API key from `OPENROUTER_API_KEY` (no CLI flag). | ||
| - Accept up to 3 input images via repeated `--input-image`. | ||
| - Save output in the current working directory. If multiple images are returned, append `-1`, `-2`, etc. | ||
| - Print `MEDIA: <path>` for each saved image. Do not read images back into the response. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| You are a visionary image‑creation artist with a poetic, dreamlike imagination. | ||
| Your role is to transform any user request—whether highly detailed or very minimal—into a vivid, concrete, and model‑ready image description. | ||
| When information is missing, infer the user's intent in a gentle and intuitive way (such as creating a character portrait, sticker design, sci‑fi avatar, creature concept, etc.). | ||
| If the user does not specify an art style, you may offer subtle optional suggestions (for example, "soft illustration," "minimal line style," or "playful entertainment‑meme style") without imposing them. | ||
|
|
||
| Your responsibilities: | ||
| - Any text that appears in the image should match the user's language. | ||
| - Create visually compelling and technically excellent images | ||
| - Pay attention to composition, lighting, color, and visual balance | ||
| - Follow the user's specific style preferences and requirements | ||
| - For image edits, preserve the original context while making requested modifications | ||
| - For multi-image composition, seamlessly blend subjects into cohesive results | ||
|
|
||
| Remember: Output only the generated image without additional commentary. |
187 changes: 187 additions & 0 deletions
187
skills/nano-banana-pro-openrouter/scripts/generate_image.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,187 @@ | ||
| #!/usr/bin/env python3 | ||
| # /// script | ||
| # requires-python = ">=3.10" | ||
| # dependencies = [ | ||
| # "openai", | ||
| # "pillow", | ||
| # ] | ||
| # /// | ||
| """ | ||
|
nblog marked this conversation as resolved.
Outdated
|
||
| Generate or edit images via OpenRouter using openai-python. | ||
| """ | ||
|
|
||
| import argparse | ||
| import base64 | ||
| import mimetypes | ||
| import os | ||
| from pathlib import Path | ||
|
|
||
|
|
||
| # Configuration | ||
| MAX_INPUT_IMAGES = 3 | ||
| MIME_TO_EXT = { | ||
| "image/png": ".png", | ||
| "image/jpeg": ".jpg", | ||
| "image/jpg": ".jpg", | ||
| "image/webp": ".webp", | ||
| } | ||
|
|
||
|
|
||
| def parse_args(): | ||
| parser = argparse.ArgumentParser(description="Generate or edit images via OpenRouter.") | ||
| parser.add_argument("--prompt", required=True, help="Prompt describing the desired image.") | ||
| parser.add_argument("--filename", required=True, help="Output filename (relative to CWD).") | ||
| parser.add_argument( | ||
| "--resolution", | ||
|
nblog marked this conversation as resolved.
Outdated
|
||
| default="1K", | ||
| help="Output resolution: 1K, 2K, or 4K.", | ||
| ) | ||
| parser.add_argument( | ||
| "--input-image", | ||
| action="append", | ||
| default=[], | ||
| help=f"Optional input image path (repeatable, max {MAX_INPUT_IMAGES}).", | ||
| ) | ||
| return parser.parse_args() | ||
|
|
||
|
|
||
| def require_api_key(): | ||
| api_key = os.environ.get("OPENROUTER_API_KEY") | ||
| if not api_key: | ||
| raise SystemExit("OPENROUTER_API_KEY is not set in the environment.") | ||
| return api_key | ||
|
|
||
|
|
||
| def encode_image_to_data_url(path: Path) -> str: | ||
| if not path.exists(): | ||
| raise SystemExit(f"Input image not found: {path}") | ||
| mime, _ = mimetypes.guess_type(path.name) | ||
| if not mime: | ||
| mime = "image/png" | ||
| data = path.read_bytes() | ||
| encoded = base64.b64encode(data).decode("utf-8") | ||
| return f"data:{mime};base64,{encoded}" | ||
|
|
||
|
|
||
| def build_message_content(prompt: str, input_images): | ||
| content = [{"type": "text", "text": prompt}] | ||
| for image_path in input_images: | ||
| data_url = encode_image_to_data_url(Path(image_path)) | ||
| content.append({"type": "image_url", "image_url": {"url": data_url}}) | ||
| return content | ||
|
|
||
|
|
||
| def parse_data_url(data_url: str): | ||
| if not data_url.startswith("data:") or ";base64," not in data_url: | ||
| raise ValueError("Image URL is not a base64 data URL.") | ||
|
nblog marked this conversation as resolved.
Outdated
|
||
| header, encoded = data_url.split(",", 1) | ||
| mime = header[5:].split(";", 1)[0] | ||
| raw = base64.b64decode(encoded) | ||
| return mime, raw | ||
|
nblog marked this conversation as resolved.
Outdated
|
||
|
|
||
|
|
||
| def resolve_output_paths(filename: str, image_count: int, mime: str): | ||
| output_path = Path(filename) | ||
| suffix = output_path.suffix | ||
| if not suffix: | ||
| suffix = MIME_TO_EXT.get(mime, ".png") | ||
| output_path = output_path.with_suffix(suffix) | ||
|
nblog marked this conversation as resolved.
Outdated
|
||
|
|
||
| if output_path.parent and not output_path.parent.exists(): | ||
| raise SystemExit(f"Output directory does not exist: {output_path.parent}") | ||
|
|
||
| if image_count == 1: | ||
| return [output_path] | ||
|
|
||
| paths = [] | ||
| for index in range(image_count): | ||
| numbered = output_path.with_name(f"{output_path.stem}-{index + 1}{suffix}") | ||
| paths.append(numbered) | ||
| return paths | ||
|
|
||
|
|
||
| def extract_image_url(image): | ||
| if isinstance(image, dict): | ||
| return image.get("image_url", {}).get("url") or image.get("url") | ||
| return None | ||
|
|
||
|
|
||
| def load_system_prompt(): | ||
| """Load system prompt from assets/SYSTEM_TEMPLATE if it exists and is not empty.""" | ||
| script_dir = Path(__file__).parent.parent | ||
| template_path = script_dir / "assets" / "SYSTEM_TEMPLATE" | ||
|
|
||
| if template_path.exists(): | ||
| content = template_path.read_text().strip() | ||
| if content: | ||
| return content | ||
| return None | ||
|
|
||
|
|
||
| def main(): | ||
| args = parse_args() | ||
|
|
||
| if len(args.input_image) > MAX_INPUT_IMAGES: | ||
| raise SystemExit(f"Too many input images: {len(args.input_image)} (max {MAX_INPUT_IMAGES}).") | ||
|
|
||
| image_size = args.resolution or "1K" | ||
|
|
||
| from openai import OpenAI | ||
| client = OpenAI(base_url="https://openrouter.ai/api/v1", api_key=require_api_key()) | ||
|
|
||
| # Build messages with optional system prompt | ||
| messages = [] | ||
|
|
||
| system_prompt = load_system_prompt() | ||
| if system_prompt: | ||
| messages.append({ | ||
| "role": "system", | ||
| "content": system_prompt, | ||
| }) | ||
|
|
||
| messages.append({ | ||
| "role": "user", | ||
| "content": build_message_content(args.prompt, args.input_image), | ||
| }) | ||
|
|
||
| response = client.chat.completions.create( | ||
| model="google/gemini-3-pro-image-preview", | ||
| messages=messages, | ||
| extra_body={ | ||
| "modalities": ["image", "text"], | ||
| # https://openrouter.ai/docs/guides/overview/multimodal/image-generation#image-configuration-options | ||
| "image_config": { | ||
| # "aspect_ratio": "16:9", | ||
| "image_size": image_size, | ||
| } | ||
| }, | ||
| ) | ||
|
|
||
| message = response.choices[0].message | ||
| images = getattr(message, "images", None) | ||
| if not images: | ||
| raise SystemExit("No images returned by the API.") | ||
|
|
||
| first_url = extract_image_url(images[0]) | ||
| if not first_url: | ||
| raise SystemExit("Image payload missing image_url.url.") | ||
| first_mime, _ = parse_data_url(first_url) | ||
| output_paths = resolve_output_paths(args.filename, len(images), first_mime) | ||
|
|
||
| saved_paths = [] | ||
| for idx, image in enumerate(images): | ||
|
nblog marked this conversation as resolved.
Outdated
|
||
| image_url = extract_image_url(image) | ||
| if not image_url: | ||
| raise SystemExit("Image payload missing image_url.url.") | ||
| _, raw = parse_data_url(image_url) | ||
| output_path = output_paths[idx] | ||
| output_path.write_bytes(raw) | ||
| saved_paths.append(output_path.resolve()) | ||
|
|
||
| for path in saved_paths: | ||
| print(f"Saved image to: {path}") | ||
| print(f"MEDIA: {path}") | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| main() | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.