diff --git a/README.md b/README.md index ae54912..2ec5985 100644 --- a/README.md +++ b/README.md @@ -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 生图节点 添加节点: diff --git a/chat_image_bridge.py b/chat_image_bridge.py index c5493c7..93b5e99 100644 --- a/chat_image_bridge.py +++ b/chat_image_bridge.py @@ -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"" + 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", diff --git a/web/chat_image_bridge.js b/web/chat_image_bridge.js index ebf407d..bd65a5d 100644 --- a/web/chat_image_bridge.js +++ b/web/chat_image_bridge.js @@ -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; + }; + }, +});