initial commit
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user