Add simplified Gemini 3.1 image node UI

This commit is contained in:
Marigold1122
2026-07-26 12:22:29 +08:00
parent 8bbfe5d132
commit 08e56a1248
3 changed files with 494 additions and 1 deletions
+51
View File
@@ -74,6 +74,57 @@ api -> Chat Image Bridge -> Chat Image Bridge
可以直接接到 `Save Image` 或其他 ComfyUI 图像节点。
## Gemini 3.1 简洁图片节点
添加节点:
```text
api -> Chat Image Bridge -> Gemini 3.1 图片生成 / 编辑
```
该节点针对 `catapi.catcard.uk` 的 Gemini 原生 `generateContent` 接口。默认地址已经填写,日常使用时前面板只需要操作提示词、输出比例和分辨率。
### 简洁界面
- 左侧图片输入全部平等命名为“图片1、图片2……”;不预设主图和参考图。
- 初始只显示图片1。连接最后一个图片口后自动增加下一个,最多14张;断开末尾多余连接后自动收起。
- 用户可以在提示词中写“以图片1的人物、图片2的服装为参考”,插件会自动给图片加顺序标签。
- 没有连接图片时自动进行文生图,连接图片后自动进行编辑或参考图生成。
- 右侧只有一个 IMAGE 输出。
- Base URL、API Key 和超时设置默认折叠在“连接设置”中。
前面板核心参数:
```text
提示词
输出比例
分辨率
```
分辨率会同时切换模型名和发送 `imageConfig.imageSize`:
| 分辨率 | 模型 |
| --- | --- |
| `1K` | `gemini-3.1-flash-image-1K` |
| `2K` | `gemini-3.1-flash-image-2K` |
| `4K` | `gemini-3.1-flash-image-4k` |
输出比例使用一个下拉框:
- `自动 / 保持原图`:不发送 `aspectRatio`。单图编辑保持输入比例,多图输入由模型结合提示词决定。
- `方形 · 1:1`:方形输出。
- `横图 · 16:9`、`横图 · 21:9` 等:横向输出。
- `竖图 · 9:16`、`竖图 · 3:4` 等:竖向输出。
- `自定义比例`:此时才显示自定义输入框,例如填写 `20:9`,节点会自动映射到最接近的服务端合法比例。
比例下方带有一个只读画幅预览,不增加额外参数。
服务端当前支持:`1:1`、`1:4`、`1:8`、`2:3`、`3:2`、`3:4`、`4:1`、`4:3`、`4:5`、`5:4`、`8:1`、`9:16`、`16:9`、`21:9`。服务端不直接接受 `20:9`,节点会将其映射为 `21:9`。
最多支持14个动态 ComfyUI `IMAGE` 输入。中转站实测可以接受20张,但模型的可靠公开能力为14张,因此节点将上限设置为14。
实测任意比例的 `1000×437` 输入图选择“自动 / 保持原图 + 4K”后输出为 `6152×2688`,基本精确保留原图比例;同一输入选择“横图 · 16:9 + 4K”时输出为 `5504×3072`。因此节点既支持保持原画幅的高清重绘,也支持改变画幅与提升分辨率同时进行。
## GPT Image 生图节点
添加节点:
+203 -1
View File
@@ -1,5 +1,6 @@
import base64
import json
import math
import re
import time
from io import BytesIO
@@ -23,6 +24,46 @@ GRSAI_PROXY_RE = re.compile(
RESOLUTION_OPTIONS = ["auto", "1K", "2K", "4K"]
ASPECT_RATIO_OPTIONS = ["auto", "1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "4:5", "5:4", "21:9"]
DEFAULT_MODEL = "gemini-3-pro-image-preview"
CATAPI_GEMINI_BASE_URL = "https://catapi.catcard.uk"
CATAPI_GEMINI_MODELS = {
"1K": "gemini-3.1-flash-image-1K",
"2K": "gemini-3.1-flash-image-2K",
"4K": "gemini-3.1-flash-image-4k",
}
CATAPI_GEMINI_ASPECT_RATIOS = [
"1:1",
"1:4",
"1:8",
"2:3",
"3:2",
"3:4",
"4:1",
"4:3",
"4:5",
"5:4",
"8:1",
"9:16",
"16:9",
"21:9",
]
CATAPI_OUTPUT_RATIO_OPTIONS = [
"自动 / 保持原图",
"方形 · 1:1",
"横图 · 16:9",
"横图 · 21:9",
"横图 · 4:3",
"横图 · 3:2",
"横图 · 5:4",
"横图 · 4:1",
"横图 · 8:1",
"竖图 · 9:16",
"竖图 · 3:4",
"竖图 · 2:3",
"竖图 · 4:5",
"竖图 · 1:4",
"竖图 · 1:8",
"自定义比例",
]
GPT_IMAGE_DEFAULT_BASE_URL = ""
GPT_IMAGE_MODELS = ["gpt-image-2-vip", "gpt-image-2", "nano-banana", "nano-banana-2", "nano-banana-pro"]
GPT_IMAGE_RESOLUTION_OPTIONS = ["auto", "1K", "2K", "4K"]
@@ -109,6 +150,10 @@ def normalize_gemini_base(base_url):
value = (base_url or "").strip().rstrip("/")
if not value:
raise ValueError("base_url is required")
for marker in ("/v1beta/models/", "/v1/models/"):
if marker in value:
value = value.split(marker, 1)[0]
break
if value.endswith("/chat/completions"):
value = value[: -len("/chat/completions")]
if value.endswith("/v1"):
@@ -172,6 +217,40 @@ def normalize_generation_option(value):
return value
def nearest_catapi_aspect_ratio(value):
value = (value or "").strip()
match = re.fullmatch(r"\s*(\d+(?:\.\d+)?)\s*[:/]\s*(\d+(?:\.\d+)?)\s*", value)
if not match:
raise ValueError("custom_aspect_ratio must look like 20:9")
width = float(match.group(1))
height = float(match.group(2))
if width <= 0 or height <= 0:
raise ValueError("custom_aspect_ratio values must be greater than zero")
target = width / height
def distance(option):
option_width, option_height = (float(part) for part in option.split(":"))
return abs(math.log(target / (option_width / option_height)))
return min(CATAPI_GEMINI_ASPECT_RATIOS, key=distance)
def resolve_catapi_output_ratio(output_ratio, custom_ratio=""):
output_ratio = (output_ratio or "").strip()
if not output_ratio or output_ratio == "自动 / 保持原图":
return "", "auto"
if output_ratio == "自定义比例":
requested = (custom_ratio or "").strip()
return nearest_catapi_aspect_ratio(requested), requested
match = re.search(r"(\d+:\d+)\s*$", output_ratio)
if not match or match.group(1) not in CATAPI_GEMINI_ASPECT_RATIOS:
raise ValueError(f"Unsupported output ratio option: {output_ratio}")
return match.group(1), match.group(1)
def gpt_image_size_for(model, resolution, aspect_ratio):
model = (model or "").strip()
resolution = (resolution or "auto").strip()
@@ -365,7 +444,13 @@ def redact_object(value):
if isinstance(value, list):
return [redact_object(item) for item in value]
if isinstance(value, dict):
return {key: redact_object(item) for key, item in value.items()}
result = {}
for key, item in value.items():
if key in ("data", "b64_json") and isinstance(item, str) and len(item) > 200:
result[key] = f"<redacted {len(item)} chars>"
else:
result[key] = redact_object(item)
return result
return value
@@ -758,6 +843,121 @@ class ChatImageBridgeAdvanced(ChatImageBridgeBase):
)
class CatAPIGeminiImage(ChatImageBridgeBase):
"""Simple Gemini 3.1 image generation and editing node for catapi.catcard.uk."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"prompt": ("STRING", {"default": "", "multiline": True}),
"output_ratio": (CATAPI_OUTPUT_RATIO_OPTIONS, {"default": "自动 / 保持原图"}),
"custom_ratio": ("STRING", {"default": "20:9", "multiline": False}),
"resolution": (list(CATAPI_GEMINI_MODELS), {"default": "1K"}),
"api_key": ("STRING", {"default": "", "multiline": False, "password": True}),
"base_url": ("STRING", {"default": CATAPI_GEMINI_BASE_URL, "multiline": False}),
"timeout_seconds": ("INT", {"default": 600, "min": 60, "max": 3600}),
},
"optional": {
"image_1": ("IMAGE",),
},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "generate"
CATEGORY = "api/Chat Image Bridge"
def generate(
self,
prompt,
output_ratio,
custom_ratio,
resolution,
api_key,
base_url,
timeout_seconds,
**kwargs,
):
if not (api_key or "").strip():
raise ValueError("api_key is required")
if not (base_url or "").strip():
raise ValueError("base_url is required")
if not (prompt or "").strip():
raise ValueError("prompt is required")
if resolution not in CATAPI_GEMINI_MODELS:
raise ValueError(f"Unsupported resolution: {resolution}")
sent_ratio, requested_ratio = resolve_catapi_output_ratio(output_ratio, custom_ratio)
model = CATAPI_GEMINI_MODELS[resolution]
endpoint = normalize_gemini_endpoint(base_url, model)
image_inputs = []
for index in range(1, 15):
image = kwargs.get(f"image_{index}")
if image is not None:
image_inputs.append((index, image))
if len(image_inputs) > 14:
raise ValueError("A maximum of 14 images is supported")
if image_inputs:
parts = [
{
"text": (
"以下图片按图片1、图片2的顺序编号。它们地位平等,"
"请严格根据用户提示理解每张图片的用途。"
)
}
]
for image_index, image in image_inputs:
parts.append({"text": f"图片{image_index}:"})
parts.append({"inlineData": tensor_to_png_inline_data(image)})
parts.append({"text": f"用户要求:\n{prompt.strip()}"})
else:
parts = [{"text": prompt.strip()}]
image_config = {"imageSize": resolution}
if sent_ratio:
image_config["aspectRatio"] = sent_ratio
payload = {
"contents": [{"role": "user", "parts": parts}],
"generationConfig": {
"responseModalities": ["IMAGE"],
"imageConfig": image_config,
},
}
data = self._post_with_retries(
endpoint=endpoint,
api_key=api_key,
payload=payload,
timeout_seconds=int(timeout_seconds),
retry_times=1,
)
refs = collect_image_references(data)
if not refs:
preview = json.dumps(redact_object(data), ensure_ascii=False)[:2000]
raise RuntimeError(f"Gemini response did not contain image data: {preview}")
output_image, _ = self._decode_refs_to_image(refs, int(timeout_seconds))
height = int(output_image.shape[1])
width = int(output_image.shape[2])
info = {
"model": model,
"resolution": resolution,
"output_ratio": output_ratio,
"requested_aspect_ratio": requested_ratio,
"sent_aspect_ratio": sent_ratio or "auto",
"input_images": len(image_inputs),
"output_width": width,
"output_height": height,
"output_aspect_ratio": round(width / height, 6),
}
print(f"[CatAPI Gemini Image] {json.dumps(info, ensure_ascii=False)}")
return (output_image,)
class GPTImageBase(ChatImageBridgeBase):
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
@@ -977,6 +1177,7 @@ RightCodesGPTImage = GPTImage
NODE_CLASS_MAPPINGS = {
"ChatImageBridge": ChatImageBridge,
"ChatImageBridgeAdvanced": ChatImageBridgeAdvanced,
"CatAPIGeminiImage": CatAPIGeminiImage,
"GPTImage": GPTImage,
"GPTImageEdit": GPTImageEdit,
"RightCodesGPTImage": GPTImage,
@@ -985,6 +1186,7 @@ NODE_CLASS_MAPPINGS = {
NODE_DISPLAY_NAME_MAPPINGS = {
"ChatImageBridge": "Chat Image Bridge",
"ChatImageBridgeAdvanced": "Chat Image Bridge Advanced",
"CatAPIGeminiImage": "Gemini 3.1 图片生成 / 编辑",
"GPTImage": "GPT Image Generate",
"GPTImageEdit": "GPT Image Edit",
"RightCodesGPTImage": "GPT Image",
+240
View File
@@ -3,6 +3,10 @@ import { api } from "/scripts/api.js";
const NODE_CLASS = "ChatImageBridge";
const FETCH_BUTTON = "Fetch Models";
const CATAPI_NODE_CLASS = "CatAPIGeminiImage";
const MAX_CATAPI_IMAGES = 14;
const CATAPI_AUTO_RATIO = "自动 / 保持原图";
const CATAPI_CUSTOM_RATIO = "自定义比例";
function findWidget(node, name) {
return node.widgets?.find((widget) => widget.name === name);
@@ -119,3 +123,239 @@ app.registerExtension({
};
},
});
function setWidgetVisible(node, widget, visible) {
if (!widget) {
return;
}
if (visible && widget.__catapiHidden) {
widget.type = widget.__catapiHidden.type;
widget.computeSize = widget.__catapiHidden.computeSize;
widget.serializeValue = widget.__catapiHidden.serializeValue;
delete widget.__catapiHidden;
} else if (!visible && !widget.__catapiHidden) {
widget.__catapiHidden = {
type: widget.type,
computeSize: widget.computeSize,
serializeValue: widget.serializeValue,
};
widget.type = "converted-widget";
widget.computeSize = () => [0, -4];
widget.serializeValue = () => widget.value;
}
const computed = node.computeSize?.() || node.size;
node.setSize?.([Math.max(node.size?.[0] || 360, 360), computed?.[1] || node.size?.[1] || 200]);
markCanvasDirty();
}
function parseRatioValue(value) {
const match = `${value || ""}`.match(/(\d+(?:\.\d+)?)\s*[:/]\s*(\d+(?:\.\d+)?)\s*$/);
if (!match) {
return null;
}
const width = Number(match[1]);
const height = Number(match[2]);
return width > 0 && height > 0 ? width / height : null;
}
function currentPreviewRatio(node) {
const outputRatio = `${findWidget(node, "output_ratio")?.value || ""}`;
if (outputRatio === CATAPI_AUTO_RATIO) {
return null;
}
if (outputRatio === CATAPI_CUSTOM_RATIO) {
return parseRatioValue(findWidget(node, "custom_ratio")?.value);
}
return parseRatioValue(outputRatio);
}
function makeRatioPreviewWidget(node) {
return {
name: "ratio_preview",
type: "custom",
value: null,
serialize: false,
computeSize() {
return [360, 62];
},
draw(ctx, _node, width, y, height) {
const ratio = currentPreviewRatio(node);
const centerX = width / 2;
const centerY = y + height / 2 + 2;
ctx.save();
ctx.lineWidth = 1.5;
ctx.strokeStyle = "#8a8a8a";
ctx.fillStyle = "#b8b8b8";
ctx.font = "12px sans-serif";
ctx.textAlign = "left";
ctx.fillText(ratio ? (ratio > 1 ? "横图" : ratio < 1 ? "竖图" : "方形") : "自动 / 保持原图", 14, centerY + 4);
if (ratio) {
const maxWidth = 105;
const maxHeight = 42;
let boxWidth = maxWidth;
let boxHeight = boxWidth / ratio;
if (boxHeight > maxHeight) {
boxHeight = maxHeight;
boxWidth = boxHeight * ratio;
}
ctx.strokeRect(centerX + 24 - boxWidth / 2, centerY - boxHeight / 2, boxWidth, boxHeight);
} else {
ctx.setLineDash([5, 4]);
ctx.strokeRect(centerX - 28, centerY - 18, 80, 36);
}
ctx.restore();
},
};
}
function catapiImageInputs(node) {
return (node.inputs || []).filter((input) => /^image_\d+$/.test(input.name));
}
function labelCatapiImageInputs(node) {
for (const input of catapiImageInputs(node)) {
const index = Number(input.name.split("_")[1]);
input.label = `图片${index}`;
}
}
function updateCatapiImageInputs(node) {
let inputs = catapiImageInputs(node);
labelCatapiImageInputs(node);
const last = inputs[inputs.length - 1];
if (last?.link != null && inputs.length < MAX_CATAPI_IMAGES) {
const nextIndex = inputs.length + 1;
node.addInput(`image_${nextIndex}`, "IMAGE");
inputs = catapiImageInputs(node);
labelCatapiImageInputs(node);
}
while (inputs.length > 1) {
const lastInput = inputs[inputs.length - 1];
const previousInput = inputs[inputs.length - 2];
if (lastInput.link != null || previousInput.link != null) {
break;
}
node.removeInput(node.inputs.indexOf(lastInput));
inputs = catapiImageInputs(node);
}
const computed = node.computeSize?.() || node.size;
node.setSize?.([Math.max(node.size?.[0] || 360, 360), computed?.[1] || node.size?.[1] || 200]);
markCanvasDirty();
}
function prepareCatapiNode(node) {
node.title = "Gemini 3.1 图片生成 / 编辑";
node.setSize?.([Math.max(node.size?.[0] || 380, 380), node.size?.[1] || 240]);
const labels = {
prompt: "提示词",
output_ratio: "输出比例",
custom_ratio: "自定义比例",
resolution: "分辨率",
api_key: "API Key",
base_url: "Base URL",
timeout_seconds: "超时时间",
};
for (const [name, label] of Object.entries(labels)) {
const widget = findWidget(node, name);
if (widget) {
widget.label = label;
}
}
const ratioWidget = findWidget(node, "output_ratio");
const customRatioWidget = findWidget(node, "custom_ratio");
const updateRatioWidgets = () => {
setWidgetVisible(node, customRatioWidget, ratioWidget?.value === CATAPI_CUSTOM_RATIO);
markCanvasDirty();
};
if (ratioWidget && !ratioWidget.__catapiCallbackWrapped) {
const originalCallback = ratioWidget.callback;
ratioWidget.callback = function (value) {
const result = originalCallback?.apply(this, arguments);
updateRatioWidgets();
return result;
};
ratioWidget.__catapiCallbackWrapped = true;
}
if (customRatioWidget && !customRatioWidget.__catapiCallbackWrapped) {
const originalCallback = customRatioWidget.callback;
customRatioWidget.callback = function () {
const result = originalCallback?.apply(this, arguments);
markCanvasDirty();
return result;
};
customRatioWidget.__catapiCallbackWrapped = true;
}
if (node.addCustomWidget && !node.widgets?.some((widget) => widget.name === "ratio_preview")) {
node.addCustomWidget(makeRatioPreviewWidget(node));
}
const advancedWidgets = ["api_key", "base_url", "timeout_seconds"].map((name) => findWidget(node, name));
node.properties = node.properties || {};
if (node.properties.catapiConnectionExpanded == null) {
node.properties.catapiConnectionExpanded = false;
}
let connectionButton = findWidget(node, "connection_settings");
const updateConnectionWidgets = () => {
const expanded = Boolean(node.properties.catapiConnectionExpanded);
for (const widget of advancedWidgets) {
setWidgetVisible(node, widget, expanded);
}
if (connectionButton) {
connectionButton.label = expanded ? "收起连接设置" : "⚙ 连接设置";
connectionButton.name = "connection_settings";
}
};
if (!connectionButton) {
connectionButton = node.addWidget("button", "connection_settings", null, () => {
node.properties.catapiConnectionExpanded = !node.properties.catapiConnectionExpanded;
updateConnectionWidgets();
});
connectionButton.serialize = false;
}
updateRatioWidgets();
updateConnectionWidgets();
updateCatapiImageInputs(node);
if (node.outputs?.[0]) {
node.outputs[0].label = "图像";
}
}
app.registerExtension({
name: "Comfy.ChatImageBridge.CatAPISimpleUI",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== CATAPI_NODE_CLASS) {
return;
}
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const result = onNodeCreated?.apply(this, arguments);
prepareCatapiNode(this);
return result;
};
const onConnectionsChange = nodeType.prototype.onConnectionsChange;
nodeType.prototype.onConnectionsChange = function () {
const result = onConnectionsChange?.apply(this, arguments);
updateCatapiImageInputs(this);
return result;
};
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
const result = onConfigure?.apply(this, arguments);
setTimeout(() => prepareCatapiNode(this), 0);
return result;
};
},
});