53.1.0 route Gemini image nodes

This commit is contained in:
chenjunhong
2026-05-22 14:47:43 +08:00
parent ea47230931
commit 334f432255
7 changed files with 791 additions and 442 deletions
+1 -2
View File
@@ -8,8 +8,7 @@ __all__ = [
__author__ = """xuyucai"""
__email__ = "986327386@qq.com"
__version__ = "53.0.3"
__version__ = "53.1.0"
from .src.Comfyui_Fd_Nodes.nodes import NODE_CLASS_MAPPINGS
from .src.Comfyui_Fd_Nodes.nodes import NODE_DISPLAY_NAME_MAPPINGS
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "Comfyui_Fd_Nodes"
version = "53.0.4" # 999开头的版本是debug版
version = "53.1.0" # 999开头的版本是debug版
description = "Custom nodes for fd project"
authors = [
{name = "xuyucai", email = "986327386@qq.com"}
@@ -0,0 +1,262 @@
import hashlib
import logging
from io import BytesIO
from typing import Optional
import numpy as np
import oss2
import requests
import torch
from PIL import Image
from ..config import (
FD_GEMINI_URL,
FD_OSS_ACCESS_KEY_ID,
FD_OSS_ACCESS_KEY_SECRET,
FD_OSS_BUCKET_NAME,
FD_OSS_ENDPOINT,
FD_OSS_URL_PATH_PREFIX_GEMINI,
FD_OSS_URL_PREFIX,
)
from .error_utils import ERROR_TIMEOUT, normalize_error_message
logger = logging.getLogger(__name__)
MODEL_NAME_MAP = {
"gemini-2.5-flash-image-preview": "google/gemini-2.5-flash-image-preview",
"gemini-3-pro-image-preview": "google/gemini-3-pro-image-preview",
"gemini-3.1-flash-image-preview": "google/gemini-3.1-flash-image-preview",
}
class GeminiImageServiceError(Exception):
pass
def summarize_text(text: str, max_chars: int = 120) -> str:
value = str(text or "")
if len(value) <= max_chars:
return value
return f"{value[:max_chars]}..."
def bytes_calculate_hex_md5(img_bytes: bytes, block_size=64 * 1024) -> str:
md5 = hashlib.md5()
for i in range(0, len(img_bytes), block_size):
md5.update(img_bytes[i:i + block_size])
return md5.hexdigest()
def bytesio_to_image_tensor(image_bytesio: BytesIO, mode: str = "RGB") -> torch.Tensor:
image = Image.open(image_bytesio).convert(mode)
image_array = np.array(image).astype(np.float32) / 255.0
return torch.from_numpy(image_array).unsqueeze(0)
def normalize_gemini_model_name(model: str) -> str:
model_name = str(model or "").strip()
if model_name.startswith("google/"):
return model_name
return MODEL_NAME_MAP.get(model_name, f"google/{model_name}" if model_name else model_name)
def compose_prompt(prompt: str, system_prompt: str = "") -> str:
prompt_text = (prompt or "").strip()
system_text = (system_prompt or "").strip()
if system_text and prompt_text:
return f"{system_text}\n\n{prompt_text}"
return system_text or prompt_text
class GeminiImageServiceClient:
def __init__(
self,
service_url: Optional[str] = None,
bucket=None,
oss_url_prefix: Optional[str] = None,
):
self.service_url = service_url or FD_GEMINI_URL
self.bucket = bucket
self.oss_url_prefix = oss_url_prefix or FD_OSS_URL_PREFIX
def _get_bucket(self):
if self.bucket is not None:
return self.bucket
missing = [
name
for name, value in {
"FD_OSS_ACCESS_KEY_ID": FD_OSS_ACCESS_KEY_ID,
"FD_OSS_ACCESS_KEY_SECRET": FD_OSS_ACCESS_KEY_SECRET,
"FD_OSS_BUCKET_NAME": FD_OSS_BUCKET_NAME,
"FD_OSS_ENDPOINT": FD_OSS_ENDPOINT,
"FD_OSS_URL_PREFIX": FD_OSS_URL_PREFIX,
}.items()
if not value
]
if missing:
raise RuntimeError(f"未配置 OSS 上传参数: {', '.join(missing)}")
auth = oss2.Auth(FD_OSS_ACCESS_KEY_ID, FD_OSS_ACCESS_KEY_SECRET)
self.bucket = oss2.Bucket(
auth=auth,
bucket_name=FD_OSS_BUCKET_NAME,
endpoint=FD_OSS_ENDPOINT,
connect_timeout=30,
)
self.oss_url_prefix = FD_OSS_URL_PREFIX
return self.bucket
def _tensor_to_png_bytes(self, image_tensor) -> bytes:
if getattr(image_tensor, "ndim", None) == 4:
image_tensor = image_tensor[0]
arr = (image_tensor.detach().cpu().numpy() * 255).clip(0, 255).astype(np.uint8)
img = Image.fromarray(arr)
max_pixels = 3072 * 3072
if img.width * img.height > max_pixels:
scale = (max_pixels / (img.width * img.height)) ** 0.5
size = (max(1, round(img.width * scale)), max(1, round(img.height * scale)))
img = img.resize(size, Image.Resampling.LANCZOS)
buf = BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
def upload_image(self, image_tensor) -> str:
image_bytes = self._tensor_to_png_bytes(image_tensor)
file_oss_path = f"{FD_OSS_URL_PATH_PREFIX_GEMINI}/{bytes_calculate_hex_md5(image_bytes)}.png"
self._get_bucket().put_object(file_oss_path, image_bytes)
print(f"upload {file_oss_path}")
return f"{self.oss_url_prefix}{file_oss_path}"
def upload_images(self, image_tensors) -> list[str]:
urls = []
for idx, image_tensor in enumerate(image_tensors):
logger.info(
"Gemini service input image %s resolution: %s",
idx,
tuple(image_tensor.squeeze().shape),
)
urls.append(self.upload_image(image_tensor))
return urls
def build_request_body(
self,
*,
prompt: str,
model: str,
image_url_list: list[str],
aspect_ratio: Optional[str] = None,
image_size: Optional[str] = None,
out_request_id: str = "",
) -> dict:
body = {
"out_request_id": out_request_id or "default",
"prompt": prompt,
"model": normalize_gemini_model_name(model),
"aspect_ratio": aspect_ratio or "",
"image_url_list": image_url_list,
}
if image_size:
body["resolution"] = image_size
return body
def summarize_request_body(self, body: dict) -> dict:
return {
"url": self.service_url,
"out_request_id": body.get("out_request_id"),
"model": body.get("model"),
"aspect_ratio": body.get("aspect_ratio"),
"resolution": body.get("resolution"),
"prompt_preview": summarize_text(body.get("prompt")),
"prompt_length": len(body.get("prompt") or ""),
"image_count": len(body.get("image_url_list") or []),
}
def call_with_image_urls(
self,
*,
prompt: str,
model: str,
image_url_list: list[str],
aspect_ratio: Optional[str] = None,
image_size: Optional[str] = None,
out_request_id: str = "",
):
if not self.service_url:
raise RuntimeError("未配置 Gemini 服务地址,请设置环境变量 FD_GEMINI_URL")
if not prompt or not prompt.strip():
raise RuntimeError("prompt 不能为空")
if not image_url_list:
raise RuntimeError("未提供图片")
body = self.build_request_body(
prompt=prompt.strip(),
model=model,
image_url_list=image_url_list,
aspect_ratio=aspect_ratio,
image_size=image_size,
out_request_id=out_request_id,
)
logger.info("Calling Gemini image service with payload=%s", self.summarize_request_body(body))
response = None
try:
response = requests.post(self.service_url, json=body, timeout=600)
if not response.ok:
raise RuntimeError(f"API 请求失败: {response.status_code}\n{response.text[:1000]}")
result = response.json()
logger.info(
"Gemini image service response summary: %s",
{
"status_code": response.status_code,
"result_image_url": result.get("result_image_url"),
"cost_time": result.get("cost_time"),
"has_error": bool(result.get("error")),
},
)
if result.get("error", {}).get("code"):
error = result["error"]
raise GeminiImageServiceError(
normalize_error_message(
error.get("message") or error["code"],
category=error["code"],
)
)
result_url = result["result_image_url"]
image_response = requests.get(result_url, timeout=300)
image_response.raise_for_status()
return bytesio_to_image_tensor(BytesIO(image_response.content), mode="RGB"), result_url, result.get("message", "")
except requests.exceptions.Timeout as exc:
raise RuntimeError(
normalize_error_message(exc, category=ERROR_TIMEOUT, fallback_detail="request timed out")
) from exc
except (KeyError, ValueError) as exc:
response_text = response.text[:500] if response is not None else ""
raise RuntimeError(normalize_error_message(f"解析响应失败: {exc}\n原始响应: {response_text}")) from exc
except GeminiImageServiceError:
raise
except Exception as exc:
raise RuntimeError(normalize_error_message(exc)) from exc
def call(
self,
*,
prompt: str,
model: str,
image_tensors,
aspect_ratio: Optional[str] = None,
image_size: Optional[str] = None,
out_request_id: str = "",
):
image_url_list = self.upload_images(image_tensors)
return self.call_with_image_urls(
prompt=prompt,
model=model,
image_url_list=image_url_list,
aspect_ratio=aspect_ratio,
image_size=image_size,
out_request_id=out_request_id,
)
@@ -0,0 +1,225 @@
import base64
import io
import json
import logging
import numpy as np
import requests
import torch
from PIL import Image
from ..config import FD_LITELLM_API_KEY, FD_LITELLM_BASE_URL
from .error_utils import ERROR_NSFW, ERROR_TIMEOUT, normalize_error_message
from .gemini_service import summarize_text
logger = logging.getLogger(__name__)
LITELLM_GEMINI_MODELS = {
"gemini-3-pro-image-preview-aistudio",
"gemini-3-pro-image-preview-siphonlab",
}
def should_use_litellm_gemini(model: str) -> bool:
return str(model or "").strip() in LITELLM_GEMINI_MODELS
def tensor_to_base64(image_tensor) -> str:
if image_tensor.ndim == 4:
image_tensor = image_tensor[0]
arr = (image_tensor.detach().cpu().numpy() * 255).clip(0, 255).astype(np.uint8)
pil_img = Image.fromarray(arr)
buf = io.BytesIO()
pil_img.save(buf, format="PNG")
return base64.b64encode(buf.getvalue()).decode("utf-8")
def base64_to_tensor(data_url: str):
if ";base64," in data_url:
_, b64 = data_url.split(";base64,", 1)
else:
b64 = data_url
img_bytes = base64.b64decode(b64)
pil_img = Image.open(io.BytesIO(img_bytes)).convert("RGB")
arr = np.array(pil_img).astype(np.float32) / 255.0
return torch.from_numpy(arr).unsqueeze(0)
def summarize_messages_for_log(messages):
summarized_messages = []
for message in messages:
summarized_message = {"role": message.get("role", "")}
content = message.get("content", [])
if isinstance(content, list):
text_parts = []
image_count = 0
for part in content:
if not isinstance(part, dict):
continue
if part.get("type") == "text":
text = part.get("text", "").strip()
if text:
text_parts.append(summarize_text(text))
elif part.get("type") in {"image_url", "image"}:
image_count += 1
if text_parts:
summarized_message["text"] = "\n".join(text_parts)
if image_count:
summarized_message["image_count"] = image_count
else:
summarized_message["content"] = content
summarized_messages.append(summarized_message)
return summarized_messages
def summarize_response_for_log(result):
summary = {"keys": list(result.keys())}
choices = result.get("choices", [])
summary["choice_count"] = len(choices)
if not choices:
return summary
choice = choices[0]
summary["finish_reason"] = choice.get("finish_reason")
message = choice.get("message", {})
summary["message_keys"] = list(message.keys())
images = message.get("images", [])
if isinstance(images, list):
summary["image_count"] = len(images)
content = message.get("content", [])
if isinstance(content, list):
content_types = []
for part in content:
if isinstance(part, dict):
part_type = part.get("type")
if part_type:
content_types.append(part_type)
if content_types:
summary["content_types"] = content_types
elif isinstance(content, str):
summary["content_length"] = len(content)
return summary
def extract_image_from_response(result):
choice = result["choices"][0]
if choice.get("finish_reason") == "content_filter":
raise RuntimeError(
normalize_error_message(
"内容被过滤 (content_filter),请修改提示词或输入图片后重试",
category=ERROR_NSFW,
)
)
msg = choice["message"]
if "images" in msg and msg["images"]:
return msg["images"][0]["image_url"]["url"]
content = msg.get("content", [])
if isinstance(content, list):
for part in content:
if isinstance(part, dict):
if part.get("type") == "image_url":
return part["image_url"]["url"]
if part.get("type") == "image":
return part.get("url") or part.get("data", "")
if isinstance(content, str) and len(content) > 100:
return content
raise KeyError(f"无法从响应中提取图片,响应结构: {list(msg.keys())}")
def build_litellm_messages(prompt: str, image_b64_list: list[str], system_prompt: str = ""):
messages = []
if system_prompt.strip():
messages.append({
"role": "system",
"content": [{"type": "text", "text": system_prompt}],
})
user_content = [{"type": "text", "text": prompt}]
for b64 in image_b64_list:
user_content.append({
"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{b64}"},
})
messages.append({"role": "user", "content": user_content})
return messages
def call_litellm_gemini_image(
*,
messages,
model: str,
aspect_ratio,
image_size,
seed: int,
out_request_id: str = "",
):
base_url = (FD_LITELLM_BASE_URL or "").rstrip("/")
api_key = FD_LITELLM_API_KEY
if not base_url or base_url == "https://your-api-base-url":
raise RuntimeError("未配置 base_url,请在环境变量 FD_LITELLM_BASE_URL 中设置")
if not api_key or api_key == "your-api-key":
raise RuntimeError("未配置 api_key,请在环境变量 FD_LITELLM_API_KEY 中设置")
url = f"{base_url}/v1/chat/completions"
payload = {
"stream": False,
"model": model,
"messages": messages,
"imageConfig": {"aspect_ratio": aspect_ratio, "image_size": image_size},
"modalities": ["image"],
}
if out_request_id:
payload["user"] = out_request_id
logger.info(
"Calling LiteLLM Gemini image API with payload=%s",
{
"url": url,
"stream": payload["stream"],
"model": payload["model"],
"imageConfig": payload["imageConfig"],
"modalities": payload["modalities"],
"user": payload.get("user"),
"seed": seed,
"messages": summarize_messages_for_log(messages),
},
)
response = None
try:
response = requests.post(
url=url,
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
data=json.dumps(payload),
timeout=600,
)
if not response.ok:
raise RuntimeError(f"API 请求失败: {response.status_code}\n{response.text[:1000]}")
result = response.json()
logger.info(
"LiteLLM Gemini image API response summary: %s",
{
"status_code": response.status_code,
**summarize_response_for_log(result),
},
)
out_data_url = extract_image_from_response(result)
except (KeyError, IndexError) as exc:
response_text = response.text[:500] if response is not None else ""
raise RuntimeError(normalize_error_message(f"解析响应失败: {exc}\n原始响应: {response_text}")) from exc
except requests.exceptions.Timeout as exc:
raise RuntimeError(
normalize_error_message(exc, category=ERROR_TIMEOUT, fallback_detail="request timed out")
) from exc
except Exception as exc:
raise RuntimeError(normalize_error_message(exc)) from exc
return base64_to_tensor(out_data_url)
@@ -1,26 +1,23 @@
import requests
import json
import base64
import logging
import random
import numpy as np
import os
from PIL import Image
import io
import torch
import traceback
from concurrent.futures import ThreadPoolExecutor, as_completed
from .config_manager import load_config
from .utils.error_utils import ERROR_NSFW, ERROR_TIMEOUT, normalize_error_message
from .utils.logging_utils import configure_default_logging
import logging
from .config import (
FD_LITELLM_API_KEY,
FD_LITELLM_BASE_URL,
from .utils.error_utils import normalize_error_message
from .utils.gemini_service import GeminiImageServiceClient, compose_prompt, summarize_text
from .utils.litellm_gemini_image import (
build_litellm_messages,
call_litellm_gemini_image,
should_use_litellm_gemini,
tensor_to_base64,
)
from .utils.logging_utils import configure_default_logging
configure_default_logging()
logger = logging.getLogger(__name__)
class ZhiYiImageToImageComboNode:
"""知衣图生图节点 - 接收最多8个图片组合并发调用 API"""
@@ -36,6 +33,9 @@ class ZhiYiImageToImageComboNode:
IMAGE_SIZES = ["4K", "2K", "1080P", "720P"]
SEED_MODES = ["随机种子", "固定种子"]
def __init__(self):
self.gemini_client = GeminiImageServiceClient()
@classmethod
def INPUT_TYPES(cls):
return {
@@ -91,16 +91,6 @@ class ZhiYiImageToImageComboNode:
CATEGORY = "知衣/图生图"
OUTPUT_NODE = False
def _tensor_to_base64(self, image_tensor):
"""将单帧 tensor (H,W,3) 转为 base64 PNG"""
if image_tensor.ndim == 4:
image_tensor = image_tensor[0]
arr = (image_tensor.numpy() * 255).clip(0, 255).astype(np.uint8)
pil_img = Image.fromarray(arr)
buf = io.BytesIO()
pil_img.save(buf, format="PNG")
return base64.b64encode(buf.getvalue()).decode("utf-8")
def _expand_images(self, combo_images):
"""展开 batch tensor 为单帧列表"""
result = []
@@ -114,141 +104,42 @@ class ZhiYiImageToImageComboNode:
result.append(t)
return result
def _base64_to_tensor(self, data_url):
if ";base64," in data_url:
_, b64 = data_url.split(";base64,", 1)
else:
b64 = data_url
img_bytes = base64.b64decode(b64)
pil_img = Image.open(io.BytesIO(img_bytes)).convert("RGB")
arr = np.array(pil_img).astype(np.float32) / 255.0
return torch.from_numpy(arr).unsqueeze(0)
def _summarize_messages_for_log(self, messages):
summarized_messages = []
for message in messages:
summarized_message = {"role": message.get("role", "")}
content = message.get("content", [])
if isinstance(content, list):
text_parts = []
image_count = 0
for part in content:
if not isinstance(part, dict):
continue
if part.get("type") == "text":
text = part.get("text", "").strip()
if text:
text_parts.append(text)
elif part.get("type") in {"image_url", "image"}:
image_count += 1
if text_parts:
summarized_message["text"] = "\n".join(text_parts)
if image_count:
summarized_message["image_count"] = image_count
else:
summarized_message["content"] = content
summarized_messages.append(summarized_message)
return summarized_messages
def _extract_image_from_response(self, result):
choice = result["choices"][0]
if choice.get("finish_reason") == "content_filter":
raise RuntimeError(
normalize_error_message(
"内容被过滤 (content_filter),请修改提示词或输入图片后重试",
category=ERROR_NSFW,
)
)
msg = choice["message"]
if "images" in msg and msg["images"]:
return msg["images"][0]["image_url"]["url"]
content = msg.get("content", [])
if isinstance(content, list):
for part in content:
if isinstance(part, dict):
if part.get("type") == "image_url":
return part["image_url"]["url"]
if part.get("type") == "image":
return part.get("url") or part.get("data", "")
if isinstance(content, str) and len(content) > 100:
return content
raise KeyError(f"无法从响应中提取图片,响应结构: {list(msg.keys())}")
MODELS_NO_SEED = {"gemini-3-pro-image-preview", "gemini-3.1-flash-image-preview", "gemini-3-pro-image-preview-aistudio", "gemini-3-pro-image-preview-siphonlab"}
def _single_request(self, url, api_key, messages, model, aspect_ratio, image_size, seed, out_request_id=""):
payload = {
"stream": "false",
"model": model,
"messages": messages,
"imageConfig": {"image_size": image_size},
"modalities": ["image"],
}
if aspect_ratio and aspect_ratio != "auto":
payload["imageConfig"]["aspect_ratio"] = aspect_ratio
if out_request_id:
payload["user"] = out_request_id
if model not in self.MODELS_NO_SEED:
payload["seed"] = seed
def _single_request(self, image_url_list, prompt, model, aspect_ratio, image_size, seed, out_request_id=""):
log_payload = {
"url": url,
"stream": payload["stream"],
"model": payload["model"],
"imageConfig": payload["imageConfig"],
"modalities": payload["modalities"],
"user": payload.get("user"),
"seed": payload.get("seed"),
"messages": self._summarize_messages_for_log(messages),
"url": self.gemini_client.service_url,
"model": model,
"aspect_ratio": aspect_ratio,
"image_size": image_size,
"out_request_id": out_request_id or None,
"seed": seed,
"prompt_preview": summarize_text(prompt),
"prompt_length": len(prompt or ""),
"image_count": len(image_url_list),
}
print(
"[知衣图生图-combo] 调用 API 参数: "
f"{json.dumps(log_payload, ensure_ascii=False)}"
)
logger.info("Calling ZhiYi image-to-image combo API with payload=%s", log_payload)
try:
response = requests.post(
url=url,
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
data=json.dumps(payload),
timeout=600,
)
if not response.ok:
raise RuntimeError(f"API 请求失败: {response.status_code}\n{response.text[:1000]}")
result = response.json()
out_data_url = self._extract_image_from_response(result)
except (KeyError, IndexError) as e:
raise RuntimeError(normalize_error_message(f"解析响应失败: {e}\n原始响应: {response.text[:500]}"))
except requests.exceptions.Timeout as exc:
raise RuntimeError(
normalize_error_message(exc, category=ERROR_TIMEOUT, fallback_detail="request timed out")
) from exc
except Exception as exc:
raise RuntimeError(normalize_error_message(exc)) from exc
return self._base64_to_tensor(out_data_url)
image, _result_url, _message = self.gemini_client.call_with_image_urls(
prompt=prompt,
model=model,
image_url_list=image_url_list,
aspect_ratio=aspect_ratio,
image_size=image_size,
out_request_id=out_request_id,
)
return image
def _build_messages(self, prompt, image_b64_list, system_prompt):
messages = []
if system_prompt.strip():
messages.append({
"role": "system",
"content": [{"type": "text", "text": system_prompt}],
})
user_content = [{"type": "text", "text": prompt}]
for b64 in image_b64_list:
user_content.append({
"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{b64}"},
})
messages.append({"role": "user", "content": user_content})
return messages
def _single_litellm_request(self, messages, model, aspect_ratio, image_size, seed, out_request_id=""):
return call_litellm_gemini_image(
messages=messages,
model=model,
aspect_ratio=aspect_ratio,
image_size=image_size,
seed=seed,
out_request_id=out_request_id,
)
def _run_concurrent(self, tasks, max_workers, label="任务"):
"""tasks: list of (idx, callable, args),并发执行,返回 (results_list, log_lines)"""
@@ -280,16 +171,8 @@ class ZhiYiImageToImageComboNode:
combo_1=None, combo_2=None, combo_3=None, combo_4=None,
combo_5=None, combo_6=None, combo_7=None, combo_8=None,
system_prompt=""):
cfg = load_config()
final_base_url = FD_LITELLM_BASE_URL
final_api_key = FD_LITELLM_API_KEY
if not final_base_url or final_base_url == "https://your-api-base-url":
raise RuntimeError("未配置 base_url,请在节点输入或环境变量 FD_LITELLM_BASE_URL 中设置")
if not final_api_key or final_api_key == "your-api-key":
raise RuntimeError("未配置 api_key,请在节点输入或环境变量 FD_LITELLM_API_KEY 中设置")
url = f"{final_base_url}/v1/chat/completions"
actual_seed = random.randint(0, 2147483647) if seed_mode == "随机种子" else seed
use_litellm = should_use_litellm_gemini(model)
combos = [c for c in [combo_1, combo_2, combo_3, combo_4, combo_5, combo_6, combo_7, combo_8] if c is not None]
if not combos:
@@ -308,12 +191,23 @@ class ZhiYiImageToImageComboNode:
raise RuntimeError("无图片")
expanded_images = self._expand_images(combo_images)
image_b64_list = [self._tensor_to_base64(t) for t in expanded_images]
if use_litellm:
image_b64_list = [tensor_to_base64(t) for t in expanded_images]
image_url_list = None
else:
image_url_list = self.gemini_client.upload_images(expanded_images)
image_b64_list = None
for p_idx, p in enumerate(combo_prompts):
messages = self._build_messages(p, image_b64_list, system_prompt)
if use_litellm:
messages = build_litellm_messages(p, image_b64_list, system_prompt)
else:
final_prompt = compose_prompt(p, system_prompt)
for b_idx in range(batch_size):
s = (actual_seed + task_idx) if seed_mode == "固定种子" else random.randint(0, 2147483647)
tasks.append((task_idx, self._single_request, (url, final_api_key, messages, model, aspect_ratio or None, image_size, s, out_request_id)))
if use_litellm:
tasks.append((task_idx, self._single_litellm_request, (messages, model, aspect_ratio or None, image_size, s, out_request_id)))
else:
tasks.append((task_idx, self._single_request, (image_url_list, final_prompt, model, aspect_ratio or None, image_size, s, out_request_id)))
task_idx += 1
except Exception as e:
normalized_error = normalize_error_message(e)
@@ -332,6 +226,7 @@ class ZhiYiImageToImageComboNode:
results, log_lines, last_error_message = self._run_concurrent(tasks, max_concurrency, label="请求")
successful = [r for r in results if r is not None]
url = "FD_LITELLM_BASE_URL/v1/chat/completions" if use_litellm else self.gemini_client.service_url
header = f"总计: {len(successful)}/{len(tasks)} 成功, url={url}"
if pre_errors:
header += f"\n预处理失败 {len(pre_errors)} 个: " + "; ".join(pre_errors)
+47 -197
View File
@@ -1,22 +1,19 @@
import requests
import json
import base64
import logging
import random
import numpy as np
import os
from PIL import Image
import io
import torch
import traceback
from concurrent.futures import ThreadPoolExecutor, as_completed
from .config_manager import load_config
from .utils.error_utils import ERROR_NSFW, ERROR_TIMEOUT, normalize_error_message
from .utils.logging_utils import configure_default_logging
import logging
from .config import (
FD_LITELLM_API_KEY,
FD_LITELLM_BASE_URL,
import torch
from .utils.error_utils import normalize_error_message
from .utils.gemini_service import GeminiImageServiceClient, compose_prompt
from .utils.litellm_gemini_image import (
build_litellm_messages,
call_litellm_gemini_image,
should_use_litellm_gemini,
tensor_to_base64,
)
from .utils.logging_utils import configure_default_logging
configure_default_logging()
logger = logging.getLogger(__name__)
@@ -93,182 +90,29 @@ class ZhiYiImageToImageNode:
CATEGORY = "知衣/图生图"
OUTPUT_NODE = False
def _tensor_to_base64(self, image_tensor):
arr = (image_tensor[0].numpy() * 255).clip(0, 255).astype(np.uint8)
pil_img = Image.fromarray(arr)
buf = io.BytesIO()
pil_img.save(buf, format="PNG")
return base64.b64encode(buf.getvalue()).decode("utf-8")
def __init__(self):
self.gemini_client = GeminiImageServiceClient()
def _base64_to_tensor(self, data_url):
if ";base64," in data_url:
_, b64 = data_url.split(";base64,", 1)
else:
b64 = data_url
img_bytes = base64.b64decode(b64)
pil_img = Image.open(io.BytesIO(img_bytes)).convert("RGB")
arr = np.array(pil_img).astype(np.float32) / 255.0
return torch.from_numpy(arr).unsqueeze(0)
def _summarize_messages_for_log(self, messages):
summarized_messages = []
for message in messages:
summarized_message = {"role": message.get("role", "")}
content = message.get("content", [])
if isinstance(content, list):
text_parts = []
image_count = 0
for part in content:
if not isinstance(part, dict):
continue
if part.get("type") == "text":
text = part.get("text", "").strip()
if text:
text_parts.append(text)
elif part.get("type") in {"image_url", "image"}:
image_count += 1
if text_parts:
summarized_message["text"] = "\n".join(text_parts)
if image_count:
summarized_message["image_count"] = image_count
else:
summarized_message["content"] = content
summarized_messages.append(summarized_message)
return summarized_messages
def _summarize_response_for_log(self, result):
summary = {"keys": list(result.keys())}
choices = result.get("choices", [])
summary["choice_count"] = len(choices)
if not choices:
return summary
choice = choices[0]
summary["finish_reason"] = choice.get("finish_reason")
message = choice.get("message", {})
summary["message_keys"] = list(message.keys())
images = message.get("images", [])
if isinstance(images, list):
summary["image_count"] = len(images)
content = message.get("content", [])
if isinstance(content, list):
content_types = []
for part in content:
if isinstance(part, dict):
part_type = part.get("type")
if part_type:
content_types.append(part_type)
if content_types:
summary["content_types"] = content_types
elif isinstance(content, str):
summary["content_length"] = len(content)
return summary
def _extract_image_from_response(self, result):
choice = result["choices"][0]
if choice.get("finish_reason") == "content_filter":
raise RuntimeError(
normalize_error_message(
"内容被过滤 (content_filter),请修改提示词或输入图片后重试",
category=ERROR_NSFW,
)
)
msg = choice["message"]
if "images" in msg and msg["images"]:
return msg["images"][0]["image_url"]["url"]
content = msg.get("content", [])
if isinstance(content, list):
for part in content:
if isinstance(part, dict):
if part.get("type") == "image_url":
return part["image_url"]["url"]
if part.get("type") == "image":
return part.get("url") or part.get("data", "")
if isinstance(content, str) and len(content) > 100:
return content
raise KeyError(f"无法从响应中提取图片,响应结构: {list(msg.keys())}")
MODELS_NO_SEED = {"gemini-3-pro-image-preview", "gemini-3.1-flash-image-preview", "gemini-3-pro-image-preview-aistudio"}
def _single_request(self, url, api_key, messages, model, aspect_ratio, image_size, seed, out_request_id="default"):
payload = {
"stream": False,
"model": model,
"messages": messages,
"imageConfig": {"image_size": image_size},
"modalities": ["image"],
"user": out_request_id,
}
if aspect_ratio and aspect_ratio != "auto":
payload["imageConfig"]["aspect_ratio"] = aspect_ratio
if model not in self.MODELS_NO_SEED:
payload["seed"] = seed
logger.info(
"Calling ZhiYi image-to-image API with payload=%s",
{
"url": url,
"stream": payload["stream"],
"model": payload["model"],
"imageConfig": payload["imageConfig"],
"modalities": payload["modalities"],
"user": payload["user"],
"seed": payload.get("seed"),
"messages": self._summarize_messages_for_log(messages),
},
def _single_request(self, image_url_list, prompt, model, aspect_ratio, image_size, seed, out_request_id="default"):
image, _result_url, _message = self.gemini_client.call_with_image_urls(
prompt=prompt,
model=model,
image_url_list=image_url_list,
aspect_ratio=aspect_ratio,
image_size=image_size,
out_request_id=out_request_id,
)
try:
response = requests.post(
url=url,
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
data=json.dumps(payload),
timeout=600,
)
if not response.ok:
raise RuntimeError(f"API 请求失败: {response.status_code}\n{response.text[:1000]}")
result = response.json()
logger.info(
"ZhiYi image-to-image API response summary: %s",
{
"status_code": response.status_code,
**self._summarize_response_for_log(result),
},
)
out_data_url = self._extract_image_from_response(result)
except (KeyError, IndexError) as e:
raise RuntimeError(normalize_error_message(f"解析响应失败: {e}\n原始响应: {response.text[:500]}"))
except requests.exceptions.Timeout as exc:
raise RuntimeError(
normalize_error_message(exc, category=ERROR_TIMEOUT, fallback_detail="request timed out")
) from exc
except Exception as exc:
raise RuntimeError(normalize_error_message(exc)) from exc
return self._base64_to_tensor(out_data_url)
return image
def _build_messages(self, prompt, image_b64_list, system_prompt):
messages = []
if system_prompt.strip():
messages.append({
"role": "system",
"content": [{"type": "text", "text": system_prompt}],
})
user_content = [{"type": "text", "text": prompt}]
for b64 in image_b64_list:
user_content.append({
"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{b64}"},
})
messages.append({"role": "user", "content": user_content})
return messages
def _single_litellm_request(self, messages, model, aspect_ratio, image_size, seed, out_request_id="default"):
return call_litellm_gemini_image(
messages=messages,
model=model,
aspect_ratio=aspect_ratio,
image_size=image_size,
seed=seed,
out_request_id=out_request_id,
)
def _run_concurrent(self, tasks, label="任务"):
"""tasks: list of (idx, callable, args),并发执行,返回 {idx: result}"""
@@ -304,16 +148,16 @@ class ZhiYiImageToImageNode:
empty = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
return (empty, 0)
cfg = load_config()
base_url = (FD_LITELLM_BASE_URL or cfg["base_url"]).rstrip("/")
api_key = FD_LITELLM_API_KEY or cfg["api_key"]
url = f"{base_url}/v1/chat/completions"
actual_seed = random.randint(0, 2147483647) if seed_mode == "随机种子" else seed
# 预先编码所有图片(共享,避免重复编码)
image_tensors = [t for t in [image_1, image_2, image_3, image_4, image_5, image_6] if t is not None]
image_b64_list = [self._tensor_to_base64(t) for t in image_tensors]
use_litellm = should_use_litellm_gemini(model)
if use_litellm:
image_b64_list = [tensor_to_base64(t) for t in image_tensors]
image_url_list = None
else:
image_url_list = self.gemini_client.upload_images(image_tensors)
image_b64_list = None
# 有效提示词列表;字符串输入按单条 prompt 处理,避免被逐字符拆分
if isinstance(prompt_list, str):
@@ -329,10 +173,16 @@ class ZhiYiImageToImageNode:
tasks = []
task_idx = 0
for p_idx, p in enumerate(prompts):
messages = self._build_messages(p, image_b64_list, system_prompt)
if use_litellm:
messages = build_litellm_messages(p, image_b64_list, system_prompt)
else:
final_prompt = compose_prompt(p, system_prompt)
for b_idx in range(batch_size):
s = (actual_seed + p_idx * batch_size + b_idx) if seed_mode == "固定种子" else random.randint(0, 2147483647)
tasks.append((task_idx, self._single_request, (url, api_key, messages, model, aspect_ratio or None, image_size, s, out_request_id)))
if use_litellm:
tasks.append((task_idx, self._single_litellm_request, (messages, model, aspect_ratio or None, image_size, s, out_request_id)))
else:
tasks.append((task_idx, self._single_request, (image_url_list, final_prompt, model, aspect_ratio or None, image_size, s, out_request_id)))
task_idx += 1
logger.info(
+197 -79
View File
@@ -18,13 +18,18 @@ from src.Comfyui_Fd_Nodes.nodes import (
)
from src.Comfyui_Fd_Nodes.prompt_nodes import EcommercePromptGenerator, PromptListSelector
from src.Comfyui_Fd_Nodes import zhiyi_image_text_node as zhiyi_image_text_module
from src.Comfyui_Fd_Nodes import zhiyi_image_to_image_node as zhiyi_image_to_image_module
from src.Comfyui_Fd_Nodes import zhiyi_text_node as zhiyi_text_module
from src.Comfyui_Fd_Nodes.zhiyi_image_text_node import ZhiYiImageTextNode
from src.Comfyui_Fd_Nodes.zhiyi_image_to_image_combo_node import ZhiYiImageToImageComboNode
from src.Comfyui_Fd_Nodes.zhiyi_image_to_image_node import ZhiYiImageToImageNode
from src.Comfyui_Fd_Nodes.zhiyi_text_node import ZhiYiTextGenNode
from src.Comfyui_Fd_Nodes.utils.error_utils import normalize_error_message
from src.Comfyui_Fd_Nodes.utils.gemini_service import (
GeminiImageServiceClient,
compose_prompt,
normalize_gemini_model_name,
)
from src.Comfyui_Fd_Nodes.utils.litellm_gemini_image import should_use_litellm_gemini
from src.Comfyui_Fd_Nodes.utils.logging_utils import (
DEFAULT_LOG_DATE_FORMAT,
DEFAULT_LOG_FORMAT,
@@ -109,88 +114,118 @@ def test_zhiyi_image_to_image_exposes_out_request_id():
assert optional_inputs["out_request_id"][1]["default"] == "default"
def test_zhiyi_image_to_image_request_logs_request_and_response_without_images(monkeypatch):
"""The image-to-image API should log request/response summaries without image payloads."""
node = ZhiYiImageToImageNode()
captured_logs = []
def test_gemini_service_builds_internal_request_body():
client = GeminiImageServiceClient(service_url="https://gemini.internal", oss_url_prefix="https://oss/")
class DummyResponse:
ok = True
status_code = 200
text = '{"choices":[{"finish_reason":"stop","message":{"images":[{"image_url":{"url":"https://example.com/img.png"}}]}}]}'
def json(self):
return {
"choices": [
{
"finish_reason": "stop",
"message": {
"images": [
{"image_url": {"url": "https://example.com/img.png"}}
]
},
}
]
}
def fake_post(url, headers, data, timeout):
captured_logs.append(("post_payload", json.loads(data)))
return DummyResponse()
def fake_extract(_result):
return "data:image/png;base64,ZmFrZQ=="
def fake_base64_to_tensor(data_url):
captured_logs.append(("data_url", data_url))
return "fake-tensor"
def fake_logger_info(message, *args):
captured_logs.append((message, args))
monkeypatch.setattr(zhiyi_image_to_image_module.requests, "post", fake_post)
monkeypatch.setattr(node, "_extract_image_from_response", fake_extract)
monkeypatch.setattr(node, "_base64_to_tensor", fake_base64_to_tensor)
monkeypatch.setattr(zhiyi_image_to_image_module.logger, "info", fake_logger_info)
messages = [
{"role": "system", "content": [{"type": "text", "text": "system prompt"}]},
{
"role": "user",
"content": [
{"type": "text", "text": "user prompt"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAA"}},
],
},
]
result = node._single_request(
url="https://example.com/v1/chat/completions",
api_key="secret",
out_request_id="req-123",
messages=messages,
model="custom-model",
body = client.build_request_body(
prompt="draw product",
model="gemini-3-pro-image-preview",
image_url_list=["https://oss/input.png"],
aspect_ratio="1:1",
image_size="4K",
seed=42,
out_request_id="req-123",
)
assert result == "fake-tensor"
request_payload = next(value for key, value in captured_logs if key == "post_payload")
assert request_payload["user"] == "req-123"
assert request_payload["seed"] == 42
assert body == {
"out_request_id": "req-123",
"prompt": "draw product",
"model": "google/gemini-3-pro-image-preview",
"aspect_ratio": "1:1",
"image_url_list": ["https://oss/input.png"],
"resolution": "4K",
}
assert client.summarize_request_body(body)["image_count"] == 1
request_log = next(args[0] for message, args in captured_logs if message == "Calling ZhiYi image-to-image API with payload=%s")
assert request_log["user"] == "req-123"
assert request_log["messages"] == [
{"role": "system", "text": "system prompt"},
{"role": "user", "text": "user prompt", "image_count": 1},
]
assert "data:image/png;base64,AAA" not in json.dumps(request_log, ensure_ascii=False)
response_log = next(args[0] for message, args in captured_logs if message == "ZhiYi image-to-image API response summary: %s")
assert response_log["status_code"] == 200
assert response_log["choice_count"] == 1
assert response_log["image_count"] == 1
def test_gemini_service_model_and_prompt_helpers():
assert normalize_gemini_model_name("gemini-2.5-flash-image-preview") == "google/gemini-2.5-flash-image-preview"
assert normalize_gemini_model_name("google/gemini-3-pro-image-preview") == "google/gemini-3-pro-image-preview"
assert should_use_litellm_gemini("gemini-3-pro-image-preview-aistudio") is True
assert should_use_litellm_gemini("gemini-3-pro-image-preview-siphonlab") is True
assert should_use_litellm_gemini("gemini-3-pro-image-preview") is False
assert compose_prompt("user prompt", "system prompt") == "system prompt\n\nuser prompt"
assert compose_prompt("user prompt", "") == "user prompt"
def test_zhiyi_image_to_image_uses_internal_gemini_service(monkeypatch):
node = ZhiYiImageToImageNode()
calls = []
class FakeClient:
def upload_images(self, image_tensors):
calls.append(("upload_images", len(image_tensors)))
return ["https://oss/input.png"]
def call_with_image_urls(self, **kwargs):
calls.append(("call", kwargs))
return torch.ones((1, 2, 2, 3), dtype=torch.float32), "https://oss/result.png", "ok"
node.gemini_client = FakeClient()
result, actual_seed = node.generate(
image_1=torch.zeros((1, 2, 2, 3), dtype=torch.float32),
prompt="user prompt",
model="gemini-3-pro-image-preview",
aspect_ratio="1:1",
image_size="4K",
batch_size=1,
seed_mode="固定种子",
seed=42,
out_request_id="req-123",
system_prompt="system prompt",
)
assert result.shape == (1, 2, 2, 3)
assert actual_seed == 42
assert calls[0] == ("upload_images", 1)
request = calls[1][1]
assert request["image_url_list"] == ["https://oss/input.png"]
assert request["prompt"] == "system prompt\n\nuser prompt"
assert request["model"] == "gemini-3-pro-image-preview"
assert request["aspect_ratio"] == "1:1"
assert request["image_size"] == "4K"
assert request["out_request_id"] == "req-123"
def test_zhiyi_image_to_image_aistudio_uses_litellm_without_model_rename(monkeypatch):
node = ZhiYiImageToImageNode()
calls = []
monkeypatch.setattr("src.Comfyui_Fd_Nodes.zhiyi_image_to_image_node.tensor_to_base64", lambda _tensor: "AAA")
def fake_litellm(messages, model, aspect_ratio, image_size, seed, out_request_id):
calls.append({
"messages": messages,
"model": model,
"aspect_ratio": aspect_ratio,
"image_size": image_size,
"seed": seed,
"out_request_id": out_request_id,
})
return torch.ones((1, 2, 2, 3), dtype=torch.float32)
monkeypatch.setattr(node, "_single_litellm_request", fake_litellm)
monkeypatch.setattr(node.gemini_client, "upload_images", lambda _image_tensors: pytest.fail("aistudio should not upload images to Gemini service"))
result, actual_seed = node.generate(
image_1=torch.zeros((1, 2, 2, 3), dtype=torch.float32),
prompt="user prompt",
model="gemini-3-pro-image-preview-aistudio",
aspect_ratio="1:1",
image_size="4K",
batch_size=1,
seed_mode="固定种子",
seed=42,
out_request_id="req-123",
system_prompt="system prompt",
)
assert result.shape == (1, 2, 2, 3)
assert actual_seed == 42
assert calls[0]["model"] == "gemini-3-pro-image-preview-aistudio"
assert calls[0]["messages"][0]["role"] == "system"
assert calls[0]["messages"][1]["content"][0]["text"] == "user prompt"
assert calls[0]["messages"][1]["content"][1]["image_url"]["url"] == "data:image/png;base64,AAA"
def test_normalize_error_message_classifies_timeout_and_nsfw():
@@ -203,8 +238,7 @@ def test_zhiyi_image_to_image_generate_returns_last_actual_error(monkeypatch):
node = ZhiYiImageToImageNode()
image = torch.zeros((1, 2, 2, 3), dtype=torch.float32)
monkeypatch.setattr(zhiyi_image_to_image_module, "load_config", lambda: {"base_url": "https://example.com", "api_key": "secret"})
monkeypatch.setattr(node, "_tensor_to_base64", lambda _tensor: "AAA")
monkeypatch.setattr(node.gemini_client, "upload_images", lambda _image_tensors: ["https://oss/input.png"])
monkeypatch.setattr(
node,
"_run_concurrent",
@@ -253,7 +287,7 @@ def test_zhiyi_image_to_image_combo_generate_returns_last_actual_error(monkeypat
"_run_concurrent",
lambda tasks, max_workers, label="任务": ([None] * len(tasks), ["[请求 1] 失败"], "UNKNOWN: API 请求失败: 500\ninternal error"),
)
monkeypatch.setattr(node, "_tensor_to_base64", lambda _tensor: "AAA")
monkeypatch.setattr(node.gemini_client, "upload_images", lambda _image_tensors: ["https://oss/input.png"])
with pytest.raises(RuntimeError, match=r"^UNKNOWN: API 请求失败: 500"):
node.generate(
@@ -266,6 +300,90 @@ def test_zhiyi_image_to_image_combo_generate_returns_last_actual_error(monkeypat
)
def test_zhiyi_image_to_image_combo_uses_internal_gemini_service(monkeypatch):
node = ZhiYiImageToImageComboNode()
node.gemini_client.service_url = "https://gemini.internal/generate"
combo = {
"images": [torch.zeros((1, 2, 2, 3), dtype=torch.float32)],
"prompts": ["prompt one", "prompt two"],
}
calls = []
monkeypatch.setattr(node.gemini_client, "upload_images", lambda image_tensors: ["https://oss/input.png"])
def fake_single_request(*args):
calls.append(args)
return torch.ones((1, 2, 2, 3), dtype=torch.float32)
monkeypatch.setattr(node, "_single_request", fake_single_request)
images, actual_seed, log_text = node.generate(
model=ZhiYiImageToImageComboNode.MODELS[0],
aspect_ratio="1:1",
image_size="4K",
batch_size=1,
max_concurrency=1,
seed_mode="固定种子",
seed=7,
out_request_id="req-456",
combo_1=combo,
system_prompt="system prompt",
)
assert len(images) == 2
assert actual_seed == 7
assert "url=https://gemini.internal/generate" in log_text
assert calls[0] == (
["https://oss/input.png"],
"system prompt\n\nprompt one",
ZhiYiImageToImageComboNode.MODELS[0],
"1:1",
"4K",
7,
"req-456",
)
assert calls[1][1] == "system prompt\n\nprompt two"
def test_zhiyi_image_to_image_combo_siphonlab_uses_litellm_without_model_rename(monkeypatch):
node = ZhiYiImageToImageComboNode()
combo = {
"images": [torch.zeros((1, 2, 2, 3), dtype=torch.float32)],
"prompts": ["prompt one"],
}
calls = []
monkeypatch.setattr("src.Comfyui_Fd_Nodes.zhiyi_image_to_image_combo_node.tensor_to_base64", lambda _tensor: "AAA")
monkeypatch.setattr(node.gemini_client, "upload_images", lambda _image_tensors: pytest.fail("siphonlab should not upload images to Gemini service"))
def fake_litellm(*args):
calls.append(args)
return torch.ones((1, 2, 2, 3), dtype=torch.float32)
monkeypatch.setattr(node, "_single_litellm_request", fake_litellm)
images, actual_seed, log_text = node.generate(
model="gemini-3-pro-image-preview-siphonlab",
aspect_ratio="1:1",
image_size="4K",
batch_size=1,
max_concurrency=1,
seed_mode="固定种子",
seed=7,
out_request_id="req-456",
combo_1=combo,
system_prompt="system prompt",
)
assert len(images) == 1
assert actual_seed == 7
assert "FD_LITELLM_BASE_URL/v1/chat/completions" in log_text
assert calls[0][1] == "gemini-3-pro-image-preview-siphonlab"
assert calls[0][0][0]["role"] == "system"
assert calls[0][0][1]["content"][0]["text"] == "prompt one"
assert calls[0][0][1]["content"][1]["image_url"]["url"] == "data:image/png;base64,AAA"
def test_zhiyi_image_text_request_logs_request_and_response_without_images(monkeypatch):
"""The image-text API should log request/response summaries without image payloads."""
node = ZhiYiImageTextNode()