From 2cccdd43b52c87f4daf41ed5971c2d006d77dbe4 Mon Sep 17 00:00:00 2001 From: Darrell Goh Date: Thu, 5 Feb 2026 14:09:53 +0800 Subject: [PATCH] initial commit --- .env.template | 37 +++ .gitignore | 48 ++++ LICENSE | 21 ++ README.md | 198 ++++++++++++++ __init__.py | 26 ++ node.py | 612 ++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 5 + web/nano_banana_v3.js | 273 +++++++++++++++++++ 8 files changed, 1220 insertions(+) create mode 100644 .env.template create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 __init__.py create mode 100644 node.py create mode 100644 requirements.txt create mode 100644 web/nano_banana_v3.js diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..677d334 --- /dev/null +++ b/.env.template @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d630ff7 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2808698 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..418d8dc --- /dev/null +++ b/README.md @@ -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 diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..1de3509 --- /dev/null +++ b/__init__.py @@ -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"] diff --git a/node.py b/node.py new file mode 100644 index 0000000..779b2a0 --- /dev/null +++ b/node.py @@ -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() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..21f969e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +aiohttp>=3.8.0 +Pillow +python-dotenv +numpy +torch diff --git a/web/nano_banana_v3.js b/web/nano_banana_v3.js new file mode 100644 index 0000000..e637fb7 --- /dev/null +++ b/web/nano_banana_v3.js @@ -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; + }, +})