613 lines
23 KiB
Python
613 lines
23 KiB
Python
"""
|
|
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()
|