Files
ycyy-ComfyUI-YCYY-API/gemini/gemini_image_node.py
T
2026-09-14 15:29:57 +08:00

455 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import io
import os
import sys
import json
import base64
import requests
import torch
import numpy as np
from PIL import Image
from io import BytesIO
from typing_extensions import override
from comfy_api.latest import ComfyExtension, io
from ..utils.image_utils import tensor_to_base64_string
from ..utils.config_utils import get_config_section
class GeminiImage(io.ComfyNode):
"""
这个节点使用谷歌Gemini Image API 生成或者修改图片
"""
@classmethod
def _load_models_from_config(cls):
"""
从config.json中加载模型列表
如果获取不到,返回默认模型列表
"""
try:
config_path = os.path.join(os.path.dirname(__file__), '..', "config.json")
if not os.path.exists(config_path):
return ["gemini-2.5-flash-image"]
with open(config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
if 'gemini-image' in config and 'models' in config['gemini-image']:
models = config['gemini-image']['models']
if isinstance(models, list) and len(models) > 0:
return models
return ["gemini-2.5-flash-image"]
except Exception:
return ["gemini-2.5-flash-image"]
@classmethod
def _load_config_credentials(cls, config_options=None):
"""
从config.json中加载并验证API凭据,如果提供了config_options则优先使用
返回 (base_url, api_key, timeout) 元组
"""
# 如果提供了配置覆盖,则使用覆盖配置
if config_options is not None:
base_url = config_options.get('base_url', '').strip()
api_key = config_options.get('api_key', '').strip()
timeout = config_options.get('timeout', 120)
# 如果覆盖配置中有有效的 base_url 和 api_key,则直接返回
if base_url and api_key:
return base_url, api_key, timeout
# 否则从配置文件加载
config_path = os.path.join(os.path.dirname(__file__), '..', "config.json")
# 检查配置文件是否存在
if not os.path.exists(config_path):
raise FileNotFoundError(f"Config file not found: {config_path}")
try:
with open(config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
# 检查是否存在gemini配置段
if 'gemini-image' not in config:
raise ValueError("Missing 'gemini-image' section in config file")
gemini_config = config['gemini-image']
# 获取并验证base_url
if 'base_url' not in gemini_config:
raise ValueError("Missing 'base_url' in gemini-image section")
base_url = gemini_config['base_url'].strip() if isinstance(gemini_config['base_url'], str) else str(gemini_config['base_url']).strip()
if not base_url:
raise ValueError("base_url cannot be empty")
# 获取并验证api_key
if 'api_key' not in gemini_config:
raise ValueError("Missing 'api_key' in gemini-image section")
api_key = gemini_config['api_key'].strip() if isinstance(gemini_config['api_key'], str) else str(gemini_config['api_key']).strip()
if not api_key:
raise ValueError("api_key cannot be empty")
# 获取timeout参数,默认值为120秒
timeout = gemini_config.get('timeout', 120)
if isinstance(timeout, str):
try:
timeout = int(timeout)
except ValueError:
timeout = 120
# 如果有配置覆盖,则使用覆盖的值(如果提供了)
if config_options is not None:
if config_options.get('base_url', '').strip():
base_url = config_options['base_url'].strip()
if config_options.get('api_key', '').strip():
api_key = config_options['api_key'].strip()
if config_options.get('timeout'):
timeout = config_options['timeout']
return base_url, api_key, timeout
except Exception as e:
raise ValueError(f"Config loading error: {str(e)}")
@classmethod
def _get_proxy_config(cls, proxy_options=None):
"""
从config.json中获取代理配置,如果提供了proxy_options则优先使用
返回 proxies 字典或 None
"""
# 如果提供了代理覆盖配置
if proxy_options is not None:
if not proxy_options.get('enable', False):
return None
proxies = {}
if proxy_options.get('http', '').strip():
proxies['http'] = proxy_options['http'].strip()
if proxy_options.get('https', '').strip():
proxies['https'] = proxy_options['https'].strip()
return proxies if proxies else None
# 否则从配置文件加载
try:
proxy_config = get_config_section('proxy')
if not proxy_config or not proxy_config.get('enable', False):
return None
proxies = {}
if proxy_config.get('http'):
proxies['http'] = proxy_config['http']
if proxy_config.get('https'):
proxies['https'] = proxy_config['https']
return proxies if proxies else None
except Exception:
return None
@classmethod
def define_schema(cls) -> io.Schema:
"""
返回一个包含该节点所有信息的模式(schema)。
一些可用类型:"Model", "Vae", "Clip", "Conditioning", "Latent", "Image", "Int", "String", "Float", "Combo"。
对于输出,应使用 "io.Model.Output",对于输入,可以使用 "io.Model.Input"。
类型可以是 "Combo" —— 这将是一个供选择的列表。
"""
# 从配置文件加载模型列表
model_options = cls._load_models_from_config()
default_model = model_options[0]
return io.Schema(
node_id="YCYY_Gemini_Image_API",
display_name="Gemini Image API",
category="YCYY/API/image",
inputs=[
io.Image.Input(
id="images",
optional=True,
tooltip="Optional image(s) to use as context for the model"
),
io.Custom("YCYY_API_CONFIG_OPTIONS").Input(
id="config_options",
optional=True,
tooltip="Optional configuration override from YCYY API Config Options"
),
io.Custom("YCYY_API_PROXY_OPTIONS").Input(
id="proxy_options",
optional=True,
tooltip="Optional proxy configuration override from YCYY API Proxy Options"
),
io.String.Input(
id="prompt",
multiline=True,
),
io.Combo.Input(
id="model",
options=model_options,
default=default_model
),
io.Combo.Input(
id="aspectRatio",
options=[
"auto",
"1:1",
"2:3",
"3:2",
"3:4",
"4:3",
"4:5",
"5:4",
"9:16",
"16:9",
"21:9"
],
default="auto",
tooltip="The model defaults to matching the output image size to that of your input image, or otherwise generates 1:1 squares. You can control the aspect ratio of the output image using the aspect ratio"
),
io.Combo.Input(
id="imageSize",
options=[
"1K",
"2K",
"4K"
],
default="1K",
tooltip="Control the resolution of the output image. 1K is approximately 1024x1024, 2K is approximately 2048x2048, 4K is approximately 4096x4096(Only effective for the gemini-3 model)."
),
io.Boolean.Input(
id="enableSearch",
default=False,
tooltip="Use the Google Search tool to generate images based on real-time information(Only effective for the gemini-3 model)"
),
io.Int.Input(
id="seed",
min=0,
max=0xFFFFFFFFFFFFFFFF,
default=0,
control_after_generate=True
)
],
outputs=[
io.Image.Output(),
io.String.Output()
],
description="This node uses the Google Gemini Image API to generate or modify images."
)
# @classmethod
# def check_lazy_status(cls, image, string_field, int_field, float_field, print_to_screen):
# """
# 返回一个需要被求值的输入名称列表。
# 如果存在任何尚未被求值的惰性输入(lazy inputs),此函数将被调用。
# 只要你返回的列表中至少有一个尚未被求值的字段(并且还有更多未求值的字段存在),
# 那么一旦请求的字段值可用,此函数将再次被调用。
# 任何已被求值的输入都将作为参数传递给此函数。任何未被求值的输入的值将为 None。
# """
# if print_to_screen == "enable":
# return ["int_field", "float_field", "string_field"]
# else:
# return []
# 执行 GeminiImage 节点
@classmethod
def execute(cls, prompt, model, aspectRatio, imageSize, enableSearch, seed, images=None, config_options=None, proxy_options=None) -> io.NodeOutput:
# 加载配置和凭据,如果提供了config_options则使用覆盖配置
base_url, api_key, timeout = cls._load_config_credentials(config_options)
# 获取代理配置,如果提供了proxy_options则使用覆盖配置
proxies = cls._get_proxy_config(proxy_options)
if not prompt:
raise ValueError("prompt cannot be empty")
api_url = base_url+"/"+model+":generateContent"
if images is not None:
return cls._edit_images(api_url,api_key,prompt,model,aspectRatio,imageSize,enableSearch,seed,images,timeout,proxies)
else:
return cls._generate_images(api_url,api_key,prompt,model,aspectRatio,imageSize,enableSearch,seed,timeout,proxies)
# 图生图模式
@classmethod
def _edit_images(cls,api_url,api_key,prompt,model,aspectRatio,imageSize,enableSearch,seed,images,timeout,proxies=None)-> io.NodeOutput:
image_parts = cls._create_image_parts(images)
image_parts.append(
{
"text": prompt
}
)
headers = {
"x-goog-api-key": api_key,
"Content-Type": "application/json"
}
payload = {
"contents": [
{
"parts": image_parts
}
],
"generationConfig": {
"responseModalities": ["TEXT", "IMAGE"]
}
}
# 根据模型类型添加imageConfig - gemini-2.5-flash-image不支持imageSize参数
if model == "gemini-2.5-flash-image":
# 对于不支持imageSize的模型,只添加aspectRatio(如果非auto)
if aspectRatio != "auto":
payload["generationConfig"]["imageConfig"] = {
"aspectRatio": aspectRatio
}
elif model == "gemini-3-pro-image-preview":
# 对于支持imageSize的模型,添加imageSize和aspectRatio
image_config = {
"imageSize": imageSize
}
if aspectRatio != "auto":
image_config["aspectRatio"] = aspectRatio
payload["generationConfig"]["imageConfig"] = image_config
if enableSearch:
payload["tools"] = [{"google_search": {}}]
# print(f"正在请求Gemini文生图API: {api_url}")
# print(f"请求载荷: {json.dumps(payload)}")
try:
resp = requests.post(api_url, headers=headers, json=payload, timeout=timeout, proxies=proxies)
return cls._parse_response(resp)
except Exception as e:
empty_image = cls._create_empty_image()
return io.NodeOutput(empty_image,f'{{"success":false,"message":"The API request failed. Please check if the interface address and key are correct."}}')
# 将图像张量输入转换为与 Gemini API 兼容的格式。
@classmethod
def _create_image_parts(cls,image_input):
image_parts: list[dict] = []
for image_index in range(image_input.shape[0]):
image_as_b64 = tensor_to_base64_string(
image_input[image_index].unsqueeze(0)
)
inlineData ={
"mimeType": "image/png",
"data": image_as_b64
}
GeminiPart = {
"inlineData": inlineData
}
image_parts.append(GeminiPart)
return image_parts
# 文生图模式
@classmethod
def _generate_images(cls,api_url,api_key,prompt,model,aspectRatio,imageSize,enableSearch,seed,timeout,proxies=None)-> io.NodeOutput:
headers = {
"x-goog-api-key": api_key,
"Content-Type": "application/json"
}
payload = {
"contents": [
{
"parts": [
{
"text": prompt
}
]
}
],
"generationConfig": {
"responseModalities": ["TEXT", "IMAGE"]
}
}
# 根据模型类型添加imageConfig - gemini-2.5-flash-image不支持imageSize参数
if model == "gemini-2.5-flash-image":
# 对于不支持imageSize的模型,只添加aspectRatio(如果非auto)
if aspectRatio != "auto":
payload["generationConfig"]["imageConfig"] = {
"aspectRatio": aspectRatio
}
elif model == "gemini-3-pro-image-preview":
# 对于支持imageSize的模型,添加imageSize和aspectRatio
image_config = {
"imageSize": imageSize
}
if aspectRatio != "auto":
image_config["aspectRatio"] = aspectRatio
payload["generationConfig"]["imageConfig"] = image_config
if enableSearch:
payload["tools"] = [{"google_search": {}}]
# print(f"正在请求Gemini文生图API: {api_url}")
# print(f"请求载荷: {json.dumps(payload)}")
try:
resp = requests.post(api_url, headers=headers, json=payload, timeout=timeout, proxies=proxies)
return cls._parse_response(resp)
except Exception as e:
empty_image = cls._create_empty_image()
return io.NodeOutput(empty_image,f'{{"success":false,"message":"The API request failed. Please check if the interface address and key are correct."}}')
# 解析response 返回内容
@classmethod
def _parse_response(cls,resp):
# 检查HTTP状态码
if resp.status_code != 200:
empty_image = cls._create_empty_image()
return (empty_image,f'{{"success":false,"message":"API request returns an error.status_code:{resp.status_code}.error_reason:{resp.text}"}}')
# 检查返回内容是否为空
if not resp.text.strip():
empty_image = cls._create_empty_image()
return (empty_image,f'{{"success":false,"message":"The API returns an empty content"}}')
try:
data = resp.json()
except Exception as json_exception:
# print(f"JSON解析失败:{json_exception}")
empty_image = cls._create_empty_image()
return (empty_image,f'{{"success":false,"message":"The API returned a JSON parsing failure"}}')
# 解析响应数据
if "candidates" in data and data["candidates"]:
candidate = data["candidates"][0]
content = candidate.get("content", {})
parts = content.get("parts", [])
# 查找图像部分
for part in parts:
if "inlineData" in part:
inline_data = part["inlineData"]
mime_type = inline_data.get("mimeType", "")
image_date = inline_data.get("data", "")
if mime_type.startswith("image/"):
comfyui_image = cls._image_data_to_comfyui_image(image_date)
# 解析usage信息
usageMetadata = data.get("usageMetadata", {})
tokens_usage = cls._format_tokens_usage(usageMetadata)
return (comfyui_image, tokens_usage)
else:
# print(f"未找到imag数据")
empty_image = cls._create_empty_image()
return (empty_image,f'{{"success":false,"message":"Imag data not found"}}')
# 将返回的图像数据解析为 comfyui 格式的 image
@classmethod
def _image_data_to_comfyui_image(cls,image_date):
try:
image_bytes = base64.b64decode(image_date)
image = Image.open(BytesIO(image_bytes)).convert("RGB")
image_np = np.array(image).astype(np.float32) / 255.0
image_tensor = torch.from_numpy(image_np).unsqueeze(0)
return image_tensor
except Exception as e:
empty_image = cls._create_empty_image()
return io.NodeOutput(empty_image,f'{{"success":false,"message":"Return image data parsing exception"}}')
# 获取token用量
@classmethod
def _format_tokens_usage(cls,usageMetadata):
if not usageMetadata:
return ""
total_tokens = usageMetadata.get('totalTokenCount', '-')
return f'{{"success":true,"message":"total_tokens:{total_tokens}"}}'
# 创建空图像
@classmethod
def _create_empty_image(cls):
try:
return torch.zeros(1, 512, 512, 3, dtype=torch.float32)
except Exception as e:
return None
# 设置 web 目录,该目录中的任何 .js 文件都将被前端加载为前端扩展
# WEB_DIRECTORY = "./somejs"
# 使用 router 添加自定义 API 路由
from aiohttp import web
from server import PromptServer
@PromptServer.instance.routes.get("/hello")
async def get_hello(request):
return web.json_response("hello")