Files
jinchan 9e6854c3d0 refactor(core): 重构和拆分核心模块,新增本地API客户端与工具
- 新增 local_api_client.py,提供统一同步HTTP调用类和API端点定义
- 新增 local_api_models.py,定义OpenAI图像生成及响应数据模型
- 新增 local_api_utils.py,包含图像张量处理、下载重试及API响应验证等工具
- 优化 node.py,移除旧实现,改为导入拆分后的模块,提高代码可维护性
- .gitignore中添加doc/目录忽略规则
2026-08-10 18:23:59 +08:00

183 lines
6.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import base64
import asyncio
import io
import math
import time
from typing import Any, Optional
import requests
import torch
import numpy as np
from PIL import Image
import comfy.utils
# 与旧版 comfy_api_nodes 实现保持一致的默认值
DEFAULT_DOWNSCALE_TOTAL_PIXELS = 1536 * 1024
DEFAULT_DATA_URI_TOTAL_PIXELS = 2048 * 2048
DEFAULT_DOWNLOAD_MAX_RETRIES = 3
DEFAULT_DOWNLOAD_RETRY_DELAY = 1.0
DEFAULT_DOWNLOAD_RETRY_BACKOFF = 2.0
MIME_TYPE_TO_PIL_FORMAT = {
"image/png": "PNG",
"image/jpeg": "JPEG",
"image/jpg": "JPEG",
"image/webp": "WEBP",
}
def validate_string(
string: str,
strip_whitespace: bool = True,
field_name: str = "prompt",
min_length: Optional[int] = None,
max_length: Optional[int] = None,
) -> None:
"""与 comfy_api_nodes 的 validate_string 行为一致:空串本身合法,只在超出长度约束时报错。"""
if string is None:
raise ValueError(f"字段 '{field_name}' 不能为 None")
if strip_whitespace:
string = string.strip()
if min_length and len(string) < min_length:
raise ValueError(f"字段 '{field_name}' 长度不能小于 {min_length},当前为 {len(string)}")
if max_length and len(string) > max_length:
raise ValueError(f"字段 '{field_name}' 长度不能大于 {max_length},当前为 {len(string)}")
def downscale_image_tensor(image: torch.Tensor, total_pixels: int = DEFAULT_DOWNSCALE_TOTAL_PIXELS) -> torch.Tensor:
"""按总像素数等比缩小图片(lanczos),与旧版 comfy 实现一致。"""
if not isinstance(image, torch.Tensor):
raise ValueError("image 必须是 torch.Tensor")
if image.dim() != 4:
raise ValueError("image 形状必须为 (B, H, W, C)")
samples = image.movedim(-1, 1)
scale_by = math.sqrt(int(total_pixels) / (samples.shape[3] * samples.shape[2]))
if scale_by >= 1:
return image
width = round(samples.shape[3] * scale_by)
height = round(samples.shape[2] * scale_by)
scaled = comfy.utils.common_upscale(samples, width, height, "lanczos", "disabled")
return scaled.movedim(1, -1)
async def download_url_to_bytesio(url: str, timeout: Optional[float] = None) -> io.BytesIO:
"""下载图片,带指数退避重试(对齐旧版 client 的重试策略)。"""
def _download() -> io.BytesIO:
delay = DEFAULT_DOWNLOAD_RETRY_DELAY
last_error: Optional[Exception] = None
for attempt in range(DEFAULT_DOWNLOAD_MAX_RETRIES):
try:
response = requests.get(url, timeout=timeout)
response.raise_for_status()
return io.BytesIO(response.content)
except requests.HTTPError as exc:
status = exc.response.status_code if exc.response is not None else None
# 4xx 属于确定性失败,重试没有意义
if status is not None and 400 <= status < 500:
raise
last_error = exc
except requests.RequestException as exc:
last_error = exc
if attempt < DEFAULT_DOWNLOAD_MAX_RETRIES - 1:
time.sleep(delay)
delay *= DEFAULT_DOWNLOAD_RETRY_BACKOFF
raise last_error if last_error else RuntimeError(f"下载图片失败: {url}")
return await asyncio.to_thread(_download)
def tensor_to_data_uri(
image_tensor: torch.Tensor,
total_pixels: Optional[int] = DEFAULT_DATA_URI_TOTAL_PIXELS,
mime_type: str = "image/png",
) -> str:
if image_tensor.dim() == 4:
if image_tensor.shape[0] != 1:
raise ValueError("image_tensor 形状必须为 (H, W, C) 或 (1, H, W, C)")
image_tensor = image_tensor[0]
if image_tensor.dim() != 3:
raise ValueError("image_tensor 形状必须为 (H, W, C)")
if total_pixels:
image_tensor = downscale_image_tensor(image_tensor.unsqueeze(0), total_pixels)[0]
image = image_tensor.detach().cpu().clamp(0.0, 1.0)
image_np = (image.numpy() * 255.0).astype("uint8")
pil_image = Image.fromarray(image_np)
image_format = MIME_TYPE_TO_PIL_FORMAT.get(mime_type.lower(), "PNG")
# JPEG 不支持 alpha 通道
if image_format == "JPEG" and pil_image.mode not in ("RGB", "L"):
pil_image = pil_image.convert("RGB")
image_bytes = io.BytesIO()
pil_image.save(image_bytes, format=image_format)
encoded = base64.b64encode(image_bytes.getvalue()).decode("utf-8")
return f"data:{mime_type};base64,{encoded}"
def bytesio_to_image_tensor(data: io.BytesIO, mode: str = "RGBA") -> torch.Tensor:
"""默认输出 RGBA(4 通道),与旧版 comfy 实现一致,保留透明背景。"""
data.seek(0)
image = Image.open(data).convert(mode)
tensor = torch.from_numpy(np.array(image).astype("float32") / 255.0)
return tensor.unsqueeze(0)
def _send_progress_text(text: str, node_id: Any) -> None:
if not node_id:
return
try:
from server import PromptServer
PromptServer.instance.send_progress_text(text, node_id)
except Exception:
pass
async def validate_and_cast_response(
response: Any,
timeout: Optional[float] = None,
node_id: Any = None,
) -> torch.Tensor:
"""把 API 响应中的全部图片转成 (B, H, W, C) 张量,与旧版 comfy 实现一致。"""
data_items = getattr(response, "data", None)
if data_items is None and isinstance(response, dict):
data_items = response.get("data")
if not data_items:
raise ValueError("API 响应中缺少图片数据")
image_tensors: list[torch.Tensor] = []
for image_item in data_items:
if isinstance(image_item, dict):
image_url = image_item.get("url")
image_b64 = image_item.get("b64_json")
else:
image_url = getattr(image_item, "url", None)
image_b64 = getattr(image_item, "b64_json", None)
if image_b64:
buffer = io.BytesIO(base64.b64decode(image_b64))
elif image_url:
if image_url.startswith("data:"):
_, _, encoded = image_url.partition(",")
buffer = io.BytesIO(base64.b64decode(encoded))
else:
_send_progress_text(f"Result URL: {image_url}", node_id)
buffer = await download_url_to_bytesio(image_url, timeout=timeout)
else:
raise ValueError("API 未返回可用的图片内容")
image_tensors.append(bytesio_to_image_tensor(buffer)[0])
return torch.stack(image_tensors, dim=0)