initial commit
This commit is contained in:
@@ -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
@@ -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/
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](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
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -0,0 +1,5 @@
|
||||
aiohttp>=3.8.0
|
||||
Pillow
|
||||
python-dotenv
|
||||
numpy
|
||||
torch
|
||||
@@ -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;
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user