initial commit

This commit is contained in:
Darrell Goh
2026-02-05 14:09:53 +08:00
commit 2cccdd43b5
8 changed files with 1220 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
# Nano Banana / Vertex AI Configuration
# Copy this file to .env and fill in your values
# Your Vertex AI API key (required)
# For Nano Banana: Get your API key from Nano Banana service
# For direct Vertex AI: Get this from Google Cloud Console
VERTEX_AI_API_KEY=your_api_key_here
# Endpoint Configuration
# Choose one of two endpoint formats:
# Option 1: Simple Endpoint (recommended for Nano Banana proxy)
# Set VERTEX_AI_USE_SIMPLE_ENDPOINT=true and provide just the base endpoint
# URL format will be: {VERTEX_AI_ENDPOINT}/google/{model}
# Example for Nano Banana: VERTEX_AI_ENDPOINT=https://nano-banana.example.com
VERTEX_AI_USE_SIMPLE_ENDPOINT=false
VERTEX_AI_ENDPOINT=https://your-nano-banana-endpoint.com
# Option 2: Standard Vertex AI Endpoint (default)
# Set VERTEX_AI_USE_SIMPLE_ENDPOINT=false or leave unset
# Requires PROJECT and LOCATION to be set
# URL format: {VERTEX_AI_ENDPOINT}/projects/{PROJECT}/locations/{LOCATION}/publishers/*/models/{model}:generateContent
# VERTEX_AI_ENDPOINT=https://aiplatform.googleapis.com/v1
# VERTEX_AI_PROJECT=your_project_id
# VERTEX_AI_LOCATION=us-central1
# Google Cloud Project ID (required for standard endpoint)
# Example: my-project-12345
VERTEX_AI_PROJECT=your_project_id
# Google Cloud Location/Region (required for standard endpoint)
# Examples: us-central1, europe-west1, asia-northeast1
VERTEX_AI_LOCATION=us-central1
# Available Models (optional - comma-separated list)
# Default: gemini-3-pro-image-preview,gemini-2.5-flash-image
VERTEX_AI_MODELS=gemini-3-pro-image-preview,gemini-2.5-flash-image
+48
View File
@@ -0,0 +1,48 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Environment
.env
.venv
env/
venv/
ENV/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
*.Zone.Identifier
# Secrets
api_keys.json
secrets.json
*.key
# Data
logs/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 darrell-goh
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+198
View File
@@ -0,0 +1,198 @@
# ComfyUI Nano Banana Node V3
A ComfyUI custom node for Google Vertex AI / Gemini image generation with **workflow-level parallel execution**. Multiple instances of this node run concurrently within a workflow, enabling faster batch processing.
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![ComfyUI](https://img.shields.io/badge/ComfyUI-0.3.x+-blue.svg)](https://github.com/comfyanonymous/ComfyUI)
> **Note**: This is the V3 (parallel execution) version designed for ComfyUI 0.3.x+. For the legacy version with chat mode and multi-output nodes, see [ComfyUI-Nanobanana_node](https://github.com/darrell-goh/ComfyUI-NanoBanana_node).
## Features
- **Workflow-Level Parallel Execution** - Multiple nodes run concurrently using ComfyUI's V3 async execution engine
- **Dynamic Image Inputs** - 3 fixed image inputs always visible, with additional slots appearing dynamically as you connect images (up to 14 total)
- **Image Generation** - Generate images with Gemini models through Vertex AI with customizable resolution (1K/2K/4K) and aspect ratios
- **Auto Aspect Ratio Detection** - Automatically detects and matches aspect ratio from input images
- **Real-time Usage Metadata Display** - Shows processing time and token counts directly on the node UI
- **Thoughts Output** - Separate output for model reasoning/thinking content (for supported models)
- **Per-Instance Logging** - Execution logs saved to `logs/` directory in JSON Lines format
## Requirements
- **ComfyUI 0.3.x or later** (requires V3 node support via `comfy_api.latest`)
- Python 3.10+
- Valid Vertex AI API key or Nano Banana proxy endpoint
### Python Dependencies
```
aiohttp>=3.8.0
Pillow
python-dotenv
numpy
torch
```
## Installation
1. **Clone the repository** into your ComfyUI `custom_nodes/` folder:
```bash
cd ComfyUI/custom_nodes
git clone https://github.com/darrell-goh/ComfyUI-NanoBanana_APIv3_node.git
```
2. **Install dependencies**:
```bash
pip install -r requirements.txt
```
3. **Configure environment variables**:
```bash
cp .env.template .env
```
Edit `.env` with your API credentials (see [Configuration](#configuration) below).
4. **Restart ComfyUI**
## Configuration
Create a `.env` file in the node folder with your API credentials:
### Option 1: Nano Banana Proxy (Recommended)
```env
VERTEX_AI_API_KEY=your_api_key_here
VERTEX_AI_USE_SIMPLE_ENDPOINT=true
VERTEX_AI_ENDPOINT=https://your-nano-banana-endpoint.com
VERTEX_AI_MODELS=gemini-3-pro-image-preview,gemini-2.5-flash-image
```
### Option 2: Direct Vertex AI
```env
VERTEX_AI_API_KEY=your_vertex_ai_api_key
VERTEX_AI_USE_SIMPLE_ENDPOINT=false
VERTEX_AI_PROJECT=your-gcp-project-id
VERTEX_AI_LOCATION=us-central1
VERTEX_AI_MODELS=gemini-3-pro-image-preview,gemini-2.5-flash-image
```
## Node Reference
### Nano Banana (Pro) V3
The main node for interacting with Gemini models through Vertex AI.
#### Inputs
| Input | Type | Description |
|-------|------|-------------|
| **system_prompt** | STRING | System prompt that sets the behavior of the model |
| **user_message_box** | STRING | User message/prompt to send to the model |
| **model** | COMBO | Gemini model to use (configured via `VERTEX_AI_MODELS`) |
| **image_generation** | BOOLEAN | Enable image generation mode |
| **resolution** | COMBO | Output resolution: `1K`, `2K`, or `4K` |
| **aspect_ratio** | COMBO | Aspect ratio: `None`, `Auto`, `1:1`, `4:3`, `3:4`, `16:9`, `9:16`, `3:2`, `2:3`, `5:4`, `4:5`, `21:9` |
| **temperature** | FLOAT | Controls randomness (0.0 - 1.0) |
| **timeout** | INT | Request timeout in seconds (30-600, default: 300) |
| **image_1-3** | IMAGE | Fixed image inputs (always visible) |
| **image_4-14** | IMAGE | Dynamic image inputs (appear when fixed inputs are connected) |
#### Outputs
| Output | Type | Description |
|--------|------|-------------|
| **Text Output** | STRING | Text response from the model |
| **Image** | IMAGE | Generated image tensor (if image generation enabled) |
| **Stats** | STRING | Execution statistics (TPS, time, token count) |
| **Thoughts** | STRING | Model's reasoning/thinking content (if available) |
### On-Node Metadata Display
When execution completes, the node displays real-time statistics:
- **Aspect Ratio** (green) - Auto-detected aspect ratio (e.g., "Auto → 16:9")
- **Processing Time** (cyan) - Request duration in seconds
- **Output Tokens** (yellow) - Number of output tokens generated
- **Thinking Tokens** (magenta) - Reasoning tokens (for supported models)
- **Total Tokens** (yellow) - Total token count
## Parallel Execution
This node leverages ComfyUI's V3 async execution model. When multiple Nano Banana V3 nodes exist in a workflow without dependencies on each other, they execute **concurrently** rather than sequentially.
### Example
A workflow with 4 independent image generation nodes will run all 4 API calls in parallel:
```
┌─────────────┐ ┌─────────────┐
│ Load Image │────▶│ NanoBanana │──┐
└─────────────┘ │ V3 #1 │ │
└─────────────┘ │
┌─────────────┐ ┌─────────────┐ │ ┌─────────────┐
│ Load Image │────▶│ NanoBanana │──┼───▶│ Output │
└─────────────┘ │ V3 #2 │ │ │ Gallery │
└─────────────┘ │ └─────────────┘
┌─────────────┐ ┌─────────────┐ │
│ Load Image │────▶│ NanoBanana │──┘
└─────────────┘ │ V3 #3 │
└─────────────┘
```
All three NanoBanana nodes execute their API requests simultaneously, significantly reducing total workflow time.
## Logging
Execution logs are saved to the `logs/` directory in JSON Lines format:
```
logs/
├── node_1.log # Logs for node ID 1
├── node_2.log # Logs for node ID 2
└── ...
```
Each log entry contains:
- Timestamp
- Workflow run ID
- Node ID
- Model used
- Usage metadata (tokens, processing time)
- Prompt and response previews
## Troubleshooting
### Node not appearing in ComfyUI
- Ensure you're running ComfyUI 0.3.x or later (V3 node support required)
- Check the ComfyUI console for import errors
- Verify all dependencies are installed
### API errors
- Check that your `.env` file is properly configured
- Verify your API key is valid
- Ensure the endpoint URL is correct
### Metadata not displaying on node
- This feature requires ComfyUI's PromptServer (should be available by default)
- Check browser console for JavaScript errors
## Changelog
### v1.0.0
- Initial V3 release with parallel execution support
- Real-time usage metadata display on node UI
- Dynamic image inputs (up to 14 images)
- Auto aspect ratio detection
- Thoughts output for reasoning models
- Per-instance JSON logging
- Support for Gemini 3 Pro and Gemini 2.5 Flash models
## License
MIT License - see [LICENSE](LICENSE) file.
## Credits
- Built for use with [ComfyUI](https://github.com/comfyanonymous/ComfyUI)
- Powered by Google Vertex AI API
+26
View File
@@ -0,0 +1,26 @@
"""
ComfyUI-NanoBanana Node V3 Package
Version: 1.0.0
This package provides the V3 async parallel execution node for Vertex AI / Gemini.
It uses ComfyUI's V3 node specification for workflow-level parallelism.
Features:
- Async execution via comfy_entrypoint
- Workflow-level parallel execution (multiple nodes run concurrently)
- Real-time usage metadata display on node UI
- Per-instance logging to logs/ directory
- Dynamic image inputs (up to 14)
- Thoughts output for reasoning models
Requirements:
- ComfyUI 0.3.x+ with comfy_api.latest support
- aiohttp for async HTTP requests
"""
from .node import comfy_entrypoint, NanoBananaNodeV3, NanoBananaExtension
WEB_DIRECTORY = "./web"
__all__ = ["comfy_entrypoint", "WEB_DIRECTORY"]
+612
View File
@@ -0,0 +1,612 @@
"""
NanoBanana Node V3 - Workflow-Level Parallel Execution with Metadata & Logging
Version: 1.0.0
This V3 node enables automatic workflow-graph level parallelism using ComfyUI's
async execution engine. Multiple instances will execute concurrently without blocking.
Features:
- Workflow-level parallel execution via async/await
- Real-time usage metadata display on node UI (processing time, token counts)
- Per-instance logging to logs/ directory with JSON Lines format
- Thoughts output for model reasoning content
- Dynamic image inputs (up to 14 images)
- Auto aspect ratio detection
V3 API Implementation:
- Inherits from io.ComfyNode for async support
- Uses async def execute() - enables asyncio.Task wrapping by ComfyUI
- Uses aiohttp instead of requests - non-blocking HTTP
- Uses define_schema() instead of INPUT_TYPES()
- Uses ComfyExtension + comfy_entrypoint() for registration
- Returns io.NodeOutput instead of tuple
- Accesses hidden params via cls.hidden.{name}
"""
import asyncio
import aiohttp
import json
import time
import base64
import io as python_io
import os
import datetime
from pathlib import Path
import numpy as np
import torch
from PIL import Image
import hashlib
from dotenv import load_dotenv
# V3 API imports
from comfy_api.latest import io, ui, ComfyExtension
# Import PromptServer for metadata display
try:
from server import PromptServer
HAS_SERVER = True
except ImportError:
HAS_SERVER = False
print("[NanoBanana V3] PromptServer not available, metadata display disabled")
# Load environment variables
load_dotenv()
# Constants
MAX_IMAGES = 14
SUPPORTED_ASPECT_RATIOS = {
"1:1": 1.0, "4:3": 4/3, "3:4": 3/4, "16:9": 16/9, "9:16": 9/16,
"3:2": 3/2, "2:3": 2/3, "5:4": 5/4, "4:5": 4/5, "21:9": 21/9,
}
# ============================================================================
# NodeLogger - Per-Instance Logging
# ============================================================================
class NodeLogger:
"""
Manages per-instance log files for Nano Banana nodes.
Each node instance gets its own log file, with workflow runs separated.
"""
def __init__(self):
self.base_path = Path(os.path.dirname(__file__)) / "logs"
self.base_path.mkdir(exist_ok=True)
def _get_log_filename(self, node_id):
"""Get the log file path for a specific node instance"""
return self.base_path / f"node_{node_id}.log"
def log_execution(self, node_id, workflow_run_id, metadata,
model, prompt_preview=None, response_preview=None,
request_index=None, total_requests=None, is_summary=False):
"""
Log a node execution with all metadata.
"""
log_file = self._get_log_filename(node_id)
log_entry = {
"timestamp": datetime.datetime.now().isoformat(),
"workflow_run_id": workflow_run_id,
"node_id": node_id,
"model": model,
"usage_metadata": metadata,
"prompt_preview": prompt_preview[:200] if prompt_preview else None,
"response_preview": response_preview[:200] if response_preview else None,
}
# Add parallel execution info if applicable
if request_index is not None:
log_entry["request_index"] = request_index
log_entry["total_requests"] = total_requests
if is_summary:
log_entry["is_summary"] = True
# Append to log file (JSON Lines format for easy parsing)
with open(log_file, 'a', encoding='utf-8') as f:
f.write(json.dumps(log_entry) + "\n")
# Also print detailed console log
self._print_console_log(log_entry)
def _print_console_log(self, entry):
"""Print formatted log entry to console"""
metadata = entry.get("usage_metadata", {})
request_index = entry.get("request_index")
total_requests = entry.get("total_requests")
is_summary = entry.get("is_summary", False)
# Format header based on type
if is_summary:
header = "=== Parallel Execution Summary ==="
elif request_index is not None:
header = f"=== Parallel Request {request_index + 1}/{total_requests} ==="
else:
header = "=== Execution Log ==="
print(f"\n[NanoBanana V3] {header}", flush=True)
print(f"[NanoBanana V3] Timestamp: {entry['timestamp']}", flush=True)
print(f"[NanoBanana V3] Node ID: {entry['node_id']}", flush=True)
print(f"[NanoBanana V3] Workflow Run: {entry['workflow_run_id']}", flush=True)
print(f"[NanoBanana V3] Model: {entry['model']}", flush=True)
print(f"[NanoBanana V3] --- Usage Metadata ---", flush=True)
print(f"[NanoBanana V3] Processing Time: {metadata.get('processingTimeMs', 0):.0f}ms", flush=True)
print(f"[NanoBanana V3] Prompt Tokens: {metadata.get('promptTokenCount', 0)}", flush=True)
print(f"[NanoBanana V3] Candidates Tokens: {metadata.get('candidatesTokenCount', 0)}", flush=True)
print(f"[NanoBanana V3] Thoughts Tokens: {metadata.get('thoughtsTokenCount', 0)}", flush=True)
print(f"[NanoBanana V3] Total Tokens: {metadata.get('totalTokenCount', 0)}", flush=True)
print(f"[NanoBanana V3] Cached Content Tokens: {metadata.get('cachedContentTokenCount', 0)}", flush=True)
if is_summary and total_requests:
print(f"[NanoBanana V3] Parallel Requests: {total_requests}", flush=True)
print(f"[NanoBanana V3] =========================\n", flush=True)
# ============================================================================
# Utility Functions
# ============================================================================
def calculate_aspect_ratio(image):
"""Calculate the closest standard aspect ratio from an image tensor."""
import math
width, height = None, None
if isinstance(image, torch.Tensor):
if image.ndim == 4:
height, width = int(image.shape[1]), int(image.shape[2])
elif image.ndim == 3:
height, width = int(image.shape[0]), int(image.shape[1])
elif isinstance(image, np.ndarray):
if image.ndim == 4:
height, width = int(image.shape[1]), int(image.shape[2])
elif image.ndim == 3:
height, width = int(image.shape[0]), int(image.shape[1])
if width is None or height is None or height <= 0 or width <= 0:
return "1:1"
actual_ratio = width / height
log_actual = math.log(actual_ratio)
closest_name = "1:1"
min_log_diff = float('inf')
for name, ratio_value in SUPPORTED_ASPECT_RATIOS.items():
log_diff = abs(log_actual - math.log(ratio_value))
if log_diff < min_log_diff:
min_log_diff = log_diff
closest_name = name
return closest_name
def image_to_base64(image):
"""Convert ComfyUI IMAGE tensor to base64 PNG string."""
if image.ndim == 4:
image = image.squeeze(0)
image_np = image.cpu().numpy()
if image_np.dtype != np.uint8:
image_np = np.clip(image_np, 0, 1)
image_np = (image_np * 255).astype(np.uint8)
pil_image = Image.fromarray(image_np, 'RGB')
buffered = python_io.BytesIO()
pil_image.save(buffered, format="PNG")
return base64.b64encode(buffered.getvalue()).decode('utf-8')
def base64_to_image(base64_str):
"""Convert base64 image string to ComfyUI tensor [1, H, W, 3]."""
try:
img_data = base64.b64decode(base64_str)
img = Image.open(python_io.BytesIO(img_data)).convert("RGB")
img_array = np.array(img).astype(np.float32) / 255.0
return torch.from_numpy(img_array).unsqueeze(0)
except Exception as e:
print(f"[NanoBanana V3] Error in base64_to_image: {e}")
return torch.zeros((1, 64, 64, 3), dtype=torch.float32)
# ============================================================================
# V3 Node Definition
# ============================================================================
class NanoBananaNodeV3(io.ComfyNode):
"""
V3 Node for Vertex AI's generateContent API with workflow-level parallelism.
Features:
- Async execution enables multiple nodes to run concurrently in workflow
- Usage metadata display on node UI
- Per-instance logging to logs/ directory
- Thoughts output for model reasoning
- Proper timing for async operations
"""
# Class-level cache for models
_models_cache = None
_last_fetch_time = 0
_cache_duration = 3600
@classmethod
def _get_models(cls):
"""Fetch available models with caching."""
current_time = time.time()
if cls._models_cache is None or (current_time - cls._last_fetch_time > cls._cache_duration):
models_env = os.getenv("VERTEX_AI_MODELS", "gemini-3-pro-image-preview,gemini-2.5-flash-image")
cls._models_cache = sorted([m.strip() for m in models_env.split(",") if m.strip()])
cls._last_fetch_time = current_time
return cls._models_cache or ["gemini-3-pro-image-preview"]
@classmethod
def define_schema(cls):
"""
V3 schema definition - replaces INPUT_TYPES, RETURN_TYPES, CATEGORY, etc.
"""
return io.Schema(
node_id="NanoBananaNodeV3",
display_name="Nano Banana (Pro) V3",
description="Vertex AI image generation with workflow-level parallel execution, metadata display, and logging",
category="LLM",
inputs=[
io.String.Input(
"system_prompt",
multiline=True,
default="You are a master artist and expert at digital art."
),
io.String.Input(
"user_message_box",
multiline=True,
default="Combine all the input images with a background of your envisioning in the year 2050."
),
io.Combo.Input(
"model",
options=cls._get_models(),
default="gemini-3-pro-image-preview"
),
io.Boolean.Input(
"image_generation",
default=True
),
io.Combo.Input(
"resolution",
options=["1K", "2K", "4K"],
default="4K"
),
io.Combo.Input(
"aspect_ratio",
options=["None", "Auto", "1:1", "4:3", "3:4", "16:9", "9:16",
"3:2", "2:3", "5:4", "4:5", "21:9"],
default="Auto"
),
io.Float.Input(
"temperature",
default=1.0,
min=0.0,
max=1.0,
step=0.1
),
io.Int.Input(
"timeout",
default=300,
min=30,
max=600,
step=10
),
# Optional image inputs (3 fixed, dynamic inputs added via JS)
io.Image.Input("image_1", optional=True),
io.Image.Input("image_2", optional=True),
io.Image.Input("image_3", optional=True),
],
outputs=[
io.String.Output("text_output", display_name="Text Output"),
io.Image.Output("image_output", display_name="Image"),
io.String.Output("stats", display_name="Stats"),
io.String.Output("thoughts", display_name="Thoughts"),
],
hidden=[
io.Hidden.unique_id, # Required for logging and metadata display
],
is_output_node=False,
)
@classmethod
async def execute(
cls,
system_prompt,
user_message_box,
model,
image_generation,
resolution,
aspect_ratio,
temperature,
timeout,
image_1=None,
image_2=None,
image_3=None,
**kwargs, # Capture dynamic image inputs (image_4, image_5, etc.) AND hidden params
):
"""
Async execute method - enables workflow-level parallel execution.
ComfyUI's execution engine wraps this in asyncio.Task, allowing
multiple independent nodes to execute concurrently.
"""
placeholder_image = torch.zeros((1, 1, 1, 3), dtype=torch.float32)
# V3 nodes access hidden params via cls.hidden.{name}
unique_id = cls.hidden.unique_id if hasattr(cls, 'hidden') and hasattr(cls.hidden, 'unique_id') else None
# Generate workflow run ID for logging
workflow_run_id = datetime.datetime.now().strftime("%Y%m%d_%H%M%S_%f")
# Get API key
api_key = os.getenv("VERTEX_AI_API_KEY", "")
if not api_key:
return io.NodeOutput(
"Error: VERTEX_AI_API_KEY not found",
placeholder_image,
"Stats N/A",
""
)
# Build endpoint URL
endpoint_base = "https://aiplatform.googleapis.com/v1"
use_simple_endpoint = os.getenv("VERTEX_AI_USE_SIMPLE_ENDPOINT", "false").lower() == "true"
if use_simple_endpoint:
endpoint = os.getenv("VERTEX_AI_ENDPOINT")
if not endpoint:
return io.NodeOutput(
"Error: VERTEX_AI_ENDPOINT must be set",
placeholder_image,
"Stats N/A",
""
)
url = f"{endpoint}/google/{model}:generateContent"
else:
project = os.getenv("VERTEX_AI_PROJECT", "project")
location = os.getenv("VERTEX_AI_LOCATION", "location")
url = f"{endpoint_base}/projects/{project}/locations/{location}/publishers/*/models/{model}:generateContent"
headers = {
"api-key": api_key,
"Content-Type": "application/json",
}
# Validate temperature (max 1.0 to match V1)
validated_temp = max(0.0, min(1.0, float(temperature)))
# Process input images (3 fixed + dynamic from kwargs)
image_data = []
fixed_images = [img for img in [image_1, image_2, image_3] if img is not None]
dynamic_images = [kwargs[k] for k in sorted(kwargs.keys()) if k.startswith('image_') and kwargs[k] is not None]
input_images = fixed_images + dynamic_images
# Calculate aspect ratio if Auto
final_aspect_ratio = aspect_ratio
if aspect_ratio == "Auto" and input_images:
final_aspect_ratio = calculate_aspect_ratio(input_images[0])
# Send aspect ratio to frontend for display
if HAS_SERVER and unique_id:
PromptServer.instance.send_sync("nanobanana.aspect_ratio", {
"node_id": str(unique_id),
"aspect_ratio": final_aspect_ratio,
"was_auto": True,
"node_type": "NanoBananaNodeV3"
})
elif aspect_ratio == "Auto":
final_aspect_ratio = "None"
# Convert images to base64
for i, img in enumerate(input_images):
try:
img_str = image_to_base64(img)
image_data.append({
"inlineData": {
"mimeType": "image/png",
"data": img_str
}
})
except Exception as e:
return io.NodeOutput(
f"Error processing image_{i+1}: {e}",
placeholder_image,
"Stats N/A",
""
)
# Build request payload
user_parts = [{"text": user_message_box}]
user_parts.extend(image_data)
data = {
"contents": [{"role": "user", "parts": user_parts}],
"systemInstruction": {"parts": [{"text": system_prompt}]},
"generationConfig": {"temperature": validated_temp}
}
# Add image generation config
if image_generation:
data["generationConfig"]["responseModalities"] = ["TEXT", "IMAGE"]
data["generationConfig"]["candidateCount"] = 1
image_config = {"image_size": resolution}
if final_aspect_ratio != "None":
image_config["aspect_ratio"] = final_aspect_ratio
data["generationConfig"]["image_config"] = image_config
# ================================================================
# ASYNC HTTP REQUEST - Non-blocking!
# Using aiohttp allows the event loop to handle other tasks
# while waiting for the API response.
# ================================================================
try:
start_time = time.time()
# Create timeout for aiohttp
client_timeout = aiohttp.ClientTimeout(total=timeout)
async with aiohttp.ClientSession(timeout=client_timeout) as session:
async with session.post(url, headers=headers, json=data) as response:
response.raise_for_status()
result = await response.json()
end_time = time.time()
elapsed_time = end_time - start_time
processing_time_ms = elapsed_time * 1000
# Process response
if not result.get("candidates") or not result["candidates"][0].get("content"):
return io.NodeOutput(
"Error: Invalid response format from API",
placeholder_image,
"Stats N/A",
""
)
candidate = result["candidates"][0]
parts = candidate.get("content", {}).get("parts", [])
text_output = ""
image_tensor = placeholder_image
thoughts_output = ""
# Extract text, thoughts, and images from parts
for part in parts:
if part.get("thought") == True and "text" in part:
# Model's internal reasoning/thinking
thought_text = part["text"]
if isinstance(thought_text, str):
thoughts_output += thought_text
elif "text" in part and isinstance(part["text"], str):
# Regular text output
text_output += part["text"]
elif "inlineData" in part:
inline_data = part["inlineData"]
if inline_data.get("mimeType", "").startswith("image"):
image_tensor = base64_to_image(inline_data.get("data", ""))
# Extract full usage metadata
usage_metadata = result.get("usageMetadata", {})
prompt_tokens = usage_metadata.get("promptTokenCount", 0)
completion_tokens = usage_metadata.get("candidatesTokenCount", 0)
tps = completion_tokens / elapsed_time if elapsed_time > 0 else 0
full_metadata = {
"processingTimeMs": processing_time_ms,
"promptTokenCount": usage_metadata.get("promptTokenCount", 0),
"candidatesTokenCount": usage_metadata.get("candidatesTokenCount", 0),
"totalTokenCount": usage_metadata.get("totalTokenCount", 0),
"thoughtsTokenCount": usage_metadata.get("thoughtsTokenCount", 0),
"cachedContentTokenCount": usage_metadata.get("cachedContentTokenCount", 0),
}
# Send usage metadata to frontend for display
if HAS_SERVER and unique_id:
PromptServer.instance.send_sync("nanobanana.usage_metadata", {
"node_id": str(unique_id),
"metadata": full_metadata,
"model": model,
"node_type": "NanoBananaNodeV3"
})
# Log to per-instance log file
if unique_id:
logger = NodeLogger()
logger.log_execution(
node_id=str(unique_id),
workflow_run_id=workflow_run_id,
metadata=full_metadata,
model=model,
prompt_preview=user_message_box if user_message_box else None,
response_preview=text_output if text_output else None
)
stats_text = f"TPS: {tps:.2f}, Time: {elapsed_time:.1f}s, Tokens: {prompt_tokens + completion_tokens}"
return io.NodeOutput(
text_output,
image_tensor,
stats_text,
thoughts_output
)
except aiohttp.ClientError as e:
error_msg = f"API Request Error: {str(e)}"
print(f"[NanoBanana V3] {error_msg}")
return io.NodeOutput(error_msg, placeholder_image, "Stats N/A", "")
except asyncio.TimeoutError:
error_msg = f"Request timed out after {timeout}s"
print(f"[NanoBanana V3] {error_msg}")
return io.NodeOutput(error_msg, placeholder_image, "Stats N/A", "")
except Exception as e:
import traceback
print(f"[NanoBanana V3] Exception: {e}")
print(f"[NanoBanana V3] Traceback: {traceback.format_exc()}")
return io.NodeOutput(f"Node Error: {str(e)}", placeholder_image, "Stats N/A", "")
@classmethod
def fingerprint_inputs(cls, **kwargs):
"""
V3 equivalent of IS_CHANGED - controls caching behavior.
Returns a fingerprint that changes when inputs change.
"""
image_hashes = []
# Get all image keys (fixed + dynamic)
image_keys = sorted([k for k in kwargs.keys() if k.startswith('image_')])
for key in image_keys:
img = kwargs.get(key)
if img is not None and isinstance(img, torch.Tensor):
try:
hasher = hashlib.sha256()
hasher.update(img.cpu().numpy().tobytes())
image_hashes.append(hasher.hexdigest())
except Exception:
image_hashes.append(f"{key}_error")
return (
kwargs.get("system_prompt"),
kwargs.get("user_message_box"),
kwargs.get("model"),
kwargs.get("image_generation"),
kwargs.get("resolution"),
kwargs.get("aspect_ratio"),
float(kwargs.get("temperature", 1.0)),
tuple(image_hashes)
)
# ============================================================================
# V3 Extension Registration
# ============================================================================
class NanoBananaExtension(ComfyExtension):
"""
V3 Extension class - replaces NODE_CLASS_MAPPINGS / NODE_DISPLAY_NAME_MAPPINGS
"""
async def get_node_list(self):
"""Return list of nodes provided by this extension."""
return [
NanoBananaNodeV3,
]
async def comfy_entrypoint():
"""
V3 entry point - ComfyUI calls this to load the extension.
Replaces the old NODE_CLASS_MAPPINGS dictionary pattern.
"""
return NanoBananaExtension()
+5
View File
@@ -0,0 +1,5 @@
aiohttp>=3.8.0
Pillow
python-dotenv
numpy
torch
+273
View File
@@ -0,0 +1,273 @@
/**
* Dynamic image inputs and metadata display for Nano Banana V3 Node
* This handles the V3 async parallel execution node
*/
import { app } from "../../../scripts/app.js"
import { api } from "../../../scripts/api.js"
const TypeSlot = {
Input: 1,
Output: 2,
};
const TypeSlotEvent = {
Connect: true,
Disconnect: false,
};
// V3 node ID
const NODE_IDS = ["NanoBananaNodeV3"];
const PREFIX = "image";
const TYPE = "IMAGE";
const MAX_IMAGES = 14; // Nano Banana Pro limit (14 for Gemini 3 Pro, 6 for Gemini 2.5 Flash)
const FIXED_IMAGE_INPUTS = 3; // image_1 through image_3 are always visible
/**
* Applies dynamic image inputs and metadata display behavior to a V3 node
*/
function applyDynamicImageInputsAndMetadata(nodeType) {
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const me = onNodeCreated?.apply(this);
// The first 3 image inputs (image_1 to image_3) are defined in the Python schema
// Dynamic inputs (image_4+) will be added when all 3 fixed inputs are connected
// Initialize aspect ratio display text and usage metadata
this.aspectRatioText = null;
this.usageMetadata = null;
return me;
}
// Override onDrawForeground to display aspect ratio and usage metadata on the node
const onDrawForeground = nodeType.prototype.onDrawForeground;
nodeType.prototype.onDrawForeground = function(ctx) {
const ret = onDrawForeground?.apply(this, arguments);
ctx.save();
ctx.font = "11px Arial";
ctx.textAlign = "center";
// Position below the title, centered horizontally
let yOffset = 18; // Start below the title bar
const lineHeight = 14;
const xPos = this.size[0] / 2; // Center of node
// Draw aspect ratio (green) - existing functionality
if (this.aspectRatioText) {
ctx.fillStyle = "#8f8";
ctx.fillText(this.aspectRatioText, xPos, yOffset);
yOffset += lineHeight;
}
// Draw usage metadata (top to bottom)
if (this.usageMetadata) {
const meta = this.usageMetadata;
// Processing time (cyan) - with parallel indicator if applicable
if (meta.processingTimeMs) {
ctx.fillStyle = "#8ff";
const timeText = meta.parallelRequests && meta.parallelRequests > 1
? `${(meta.processingTimeMs / 1000).toFixed(1)}s (${meta.parallelRequests}x)`
: `${(meta.processingTimeMs / 1000).toFixed(1)}s`;
ctx.fillText(timeText, xPos, yOffset);
yOffset += lineHeight;
}
// Candidates/Output tokens (yellow)
if (meta.candidatesTokenCount) {
ctx.fillStyle = "#ff8";
ctx.fillText(`Out: ${meta.candidatesTokenCount}`, xPos, yOffset);
yOffset += lineHeight;
}
// Thoughts tokens (magenta, if present - indicates reasoning model)
if (meta.thoughtsTokenCount && meta.thoughtsTokenCount > 0) {
ctx.fillStyle = "#f8f";
ctx.fillText(`Think: ${meta.thoughtsTokenCount}`, xPos, yOffset);
yOffset += lineHeight;
}
// Total tokens (yellow)
if (meta.totalTokenCount) {
ctx.fillStyle = "#ff8";
ctx.fillText(`Total: ${meta.totalTokenCount}`, xPos, yOffset);
yOffset += lineHeight;
}
}
ctx.restore();
return ret;
}
// Handle dynamic image input connections
const onConnectionsChange = nodeType.prototype.onConnectionsChange
nodeType.prototype.onConnectionsChange = function (slotType, slot_idx, event, link_info, node_slot) {
const me = onConnectionsChange?.apply(this, arguments);
if (slotType === TypeSlot.Input) {
// Only process image inputs
if (node_slot && !node_slot.name.startsWith(PREFIX)) {
return me;
}
if (link_info && event === TypeSlotEvent.Connect) {
// Get the parent (left side node) from the link
const fromNode = this.graph._nodes.find(
(otherNode) => otherNode.id == link_info.origin_id
)
if (fromNode) {
// Make sure there is a parent for the link
const parent_link = fromNode.outputs[link_info.origin_slot];
if (parent_link) {
node_slot.type = parent_link.type;
// For dynamic slots (not the fixed 3), mark for renaming
if (!node_slot.name.match(/^image_[1-3]$/)) {
node_slot.name = `${PREFIX}_`;
}
}
}
}
// Get all image inputs
const imageInputs = this.inputs.filter(slot => slot.name.startsWith(PREFIX));
// Separate fixed inputs (image_1 to image_3) from dynamic inputs
const fixedInputs = imageInputs.filter(slot => slot.name.match(/^image_[1-3]$/));
const dynamicInputs = imageInputs.filter(slot => !slot.name.match(/^image_[1-3]$/));
// Check if all 4 fixed inputs are connected
const allFixedConnected = fixedInputs.length === FIXED_IMAGE_INPUTS &&
fixedInputs.every(slot => slot.link !== null);
// Count total connected image inputs
const connectedImageCount = imageInputs.filter(slot => slot.link !== null).length;
// Handle dynamic inputs cleanup and renaming
let toRemove = [];
let dynamicIndex = FIXED_IMAGE_INPUTS + 1; // Start from 4
for (let i = 0; i < this.inputs.length; i++) {
const slot = this.inputs[i];
// Skip non-image inputs and fixed inputs
if (!slot.name.startsWith(PREFIX) || slot.name.match(/^image_[1-3]$/)) {
continue;
}
// Dynamic slot handling
if (slot.link === null) {
// Check if this is NOT the last dynamic slot - if so, mark for removal
const isLastDynamic = dynamicInputs.indexOf(slot) === dynamicInputs.length - 1;
if (!isLastDynamic) {
toRemove.push(i);
}
} else {
// Connected dynamic slot - update name with proper index
slot.name = `${PREFIX}_${dynamicIndex}`;
dynamicIndex++;
}
}
// Remove empty non-last dynamic slots from highest index to lowest
toRemove.reverse();
for (const removeIdx of toRemove) {
this.removeInput(removeIdx);
}
// Check if we need to add a new dynamic slot
// Only add if: all 4 fixed are connected AND either no dynamic slots exist or last dynamic is connected
if (allFixedConnected && connectedImageCount < MAX_IMAGES) {
// Re-get dynamic inputs after removal
const currentDynamicInputs = this.inputs.filter(
slot => slot.name.startsWith(PREFIX) && !slot.name.match(/^image_[1-3]$/)
);
const lastDynamic = currentDynamicInputs[currentDynamicInputs.length - 1];
// Add new slot if no dynamic inputs exist, or last one is connected
if (currentDynamicInputs.length === 0 || (lastDynamic && lastDynamic.link !== null)) {
this.addInput(PREFIX, TYPE);
// Set the unconnected slot to appear gray
const newSlot = this.inputs[this.inputs.length - 1];
if (newSlot) {
newSlot.color_off = "#666";
}
}
}
// Force the node to resize itself for the new/deleted connections
this?.graph?.setDirtyCanvas(true);
return me;
}
}
return nodeType;
}
app.registerExtension({
name: 'NanoBananaV3.DynamicInputsAndMetadata',
async setup() {
// Listen for aspect ratio messages from the server
api.addEventListener("nanobanana.aspect_ratio", (event) => {
const { node_id, aspect_ratio, was_auto, node_type } = event.detail;
// Only handle V3 nodes
if (node_type !== "NanoBananaNodeV3") {
return;
}
// Find the node by its ID from the event (supports parallel execution)
const nodeIdNum = parseInt(node_id, 10);
if (nodeIdNum) {
const node = app.graph.getNodeById(nodeIdNum);
if (node && NODE_IDS.includes(node.comfyClass)) {
// Update the aspect ratio text on the node
if (was_auto) {
node.aspectRatioText = `Auto → ${aspect_ratio}`;
} else {
node.aspectRatioText = aspect_ratio !== "None" ? aspect_ratio : "";
}
// Redraw the node
app.graph.setDirtyCanvas(true);
}
}
});
// Listen for usage metadata messages from the server
api.addEventListener("nanobanana.usage_metadata", (event) => {
const { node_id, metadata, model, node_type } = event.detail;
// Only handle V3 nodes
if (node_type !== "NanoBananaNodeV3") {
return;
}
// Find the node by its ID from the event (supports parallel execution)
const nodeIdNum = parseInt(node_id, 10);
if (nodeIdNum) {
const node = app.graph.getNodeById(nodeIdNum);
if (node && NODE_IDS.includes(node.comfyClass)) {
// Store metadata for display
node.usageMetadata = metadata;
// Redraw the node
app.graph.setDirtyCanvas(true);
}
}
});
},
async beforeRegisterNodeDef(nodeType, nodeData, app) {
// Skip if not one of our V3 nodes
if (!NODE_IDS.includes(nodeData.name)) {
return
}
applyDynamicImageInputsAndMetadata(nodeType);
return nodeType;
},
})