53.1.0 route Gemini image nodes
This commit is contained in:
+1
-2
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user