Update image and video workflow nodes

This commit is contained in:
o1key
2026-05-28 16:44:30 +08:00
parent 3f0f4099fb
commit 5d9aff9ca7
21 changed files with 3507 additions and 381 deletions
+27 -22
View File
@@ -17,6 +17,17 @@ from ..utils.config import get_api_key_or_raise, get_async_api_base_url, NETWORK
from ..utils.r2_uploader import upload_video, upload_image
from ..utils.image_utils import tensor_to_pil
from ..utils.http_error import async_request_with_retry
from ..utils.video_task import (
check_interrupt,
extract_error_message,
extract_progress,
extract_status,
extract_video_url,
interruptible_sleep,
is_failure_status,
is_success_status,
run_with_interrupt,
)
try:
from comfy_api.latest import InputImpl
@@ -183,8 +194,10 @@ class K3MotionControl:
# ── 图片 & 视频上传 R2 → 获取公网 URL ────────────────────────
_stage("uploading")
check_interrupt()
pil_list = tensor_to_pil(参考图片)
image_url = await upload_image(pil_list[0].convert("RGB"))
check_interrupt()
video_url = await upload_video(参考视频)
# ── 构建请求体 ────────────────────────────────────────────────
@@ -207,13 +220,15 @@ class K3MotionControl:
async with aiohttp.ClientSession(connector=connector) as session:
# 1. 提交任务
check_interrupt()
_stage("submitting")
create_url = f"{base_url}{_ENDPOINT_CREATE}"
resp = await async_request_with_retry(
resp = await run_with_interrupt(async_request_with_retry(
session, "POST", create_url,
data=json.dumps(body, ensure_ascii=False).encode("utf-8"),
headers=headers, prefix="K3 动作控制提交: "
)
))
check_interrupt()
text = await resp.text()
create_resp = json.loads(text)
@@ -233,7 +248,8 @@ class K3MotionControl:
video_result_url = None
while True:
await asyncio.sleep(interval)
await interruptible_sleep(interval)
check_interrupt()
async with session.get(status_url, headers=headers) as resp:
text = await resp.text()
if resp.status != 200:
@@ -247,30 +263,17 @@ class K3MotionControl:
# 兼容扁平结构和 data 嵌套结构
data = sr.get("data", sr)
status = (data.get("status") or sr.get("status") or "").lower()
status = extract_status(sr)
pct_raw = data.get("progress", 0)
try:
pct = int(str(pct_raw).rstrip("%").strip())
except (ValueError, AttributeError):
pct = 0
pct = extract_progress(sr)
print(f"[K3 动作控制] 生成中 {pct}%")
_progress(pct)
if status in ("success", "completed", "done", "finished", "succeed"):
video_result_url = (
data.get("video_url")
or data.get("result_url")
or data.get("url")
or (data.get("result", {}) or {}).get("url")
or sr.get("video_url")
or sr.get("url")
)
if is_success_status(status):
video_result_url = extract_video_url(sr)
break
elif status in ("failed", "fail"):
err_info = data.get("error") or sr.get("error") or {}
err_msg = (err_info.get("message", "未知错误")
if isinstance(err_info, dict) else str(err_info))
elif is_failure_status(status, sr):
err_msg = extract_error_message(sr)
raise RuntimeError(f"K3 动作控制生成失败:{err_msg}")
interval = min(interval * 1.3, _POLL_MAX)
@@ -279,6 +282,7 @@ class K3MotionControl:
raise RuntimeError(f"API 未返回视频 URL,响应:{sr}")
# 3. 下载视频
check_interrupt()
_stage("downloading")
async with session.get(video_result_url, allow_redirects=True) as resp:
if resp.status != 200:
@@ -286,6 +290,7 @@ class K3MotionControl:
os.close(tmp_fd)
with open(save_path, "wb") as f:
async for chunk in resp.content.iter_chunked(8192):
check_interrupt()
f.write(chunk)
_stage("done")
+25 -22
View File
@@ -14,6 +14,17 @@ import aiohttp
from ..utils.config import get_api_key_or_raise, get_async_api_base_url, NETWORK_ROUTE_OPTIONS, get_base_url_by_route
from ..utils.image_utils import tensor_to_pil, encode_image_to_base64
from ..utils.http_error import async_request_with_retry
from ..utils.video_task import (
check_interrupt,
extract_error_message,
extract_progress,
extract_status,
extract_video_url,
interruptible_sleep,
is_failure_status,
is_success_status,
run_with_interrupt,
)
try:
from comfy_api.latest import InputImpl
@@ -246,11 +257,13 @@ class K3Video:
async with aiohttp.ClientSession(connector=connector) as session:
# 1. 提交
check_interrupt()
_stage("submitting")
create_url = f"{base_url}{_ENDPOINT_CREATE}"
resp = await async_request_with_retry(
resp = await run_with_interrupt(async_request_with_retry(
session, "POST", create_url, json=body, headers=headers, prefix="K3 提交: "
)
))
check_interrupt()
text = await resp.text()
create_resp = json.loads(text)
@@ -269,6 +282,7 @@ class K3Video:
video_url = None
while True:
check_interrupt()
async with session.get(status_url, headers=headers) as resp:
text = await resp.text()
if resp.status != 200:
@@ -281,39 +295,27 @@ class K3Video:
sr = json.loads(text)
data = sr.get("data", sr)
status = (data.get("status") or sr.get("status") or "").lower()
status = extract_status(sr)
pct_raw = data.get("progress", 0)
try:
pct = int(str(pct_raw).rstrip("%").strip())
except (ValueError, AttributeError):
pct = 0
pct = extract_progress(sr)
print(f"[K3 {tag}] 生成中 {pct}%")
_progress(pct)
if status in ("success", "completed", "done", "finished", "succeed"):
video_url = (
data.get("video_url")
or data.get("result_url")
or data.get("url")
or (data.get("result", {}) or {}).get("url")
or sr.get("video_url")
or sr.get("url")
)
if is_success_status(status):
video_url = extract_video_url(sr)
break
if status in ("failed", "fail"):
err_info = data.get("error") or sr.get("error") or {}
err_msg = (err_info.get("message", "未知错误")
if isinstance(err_info, dict) else str(err_info))
if is_failure_status(status, sr):
err_msg = extract_error_message(sr)
raise RuntimeError(f"K3 生成失败:{err_msg}")
await asyncio.sleep(interval)
await interruptible_sleep(interval)
interval = min(interval * 1.5, _POLL_MAX)
if not video_url:
raise RuntimeError(f"API 未返回视频 URL,响应:{sr}")
# 3. 下载
check_interrupt()
_stage("downloading")
async with session.get(video_url, allow_redirects=True) as resp:
if resp.status != 200:
@@ -321,6 +323,7 @@ class K3Video:
os.close(tmp_fd)
with open(save_path, "wb") as f:
async for chunk in resp.content.iter_chunked(8192):
check_interrupt()
f.write(chunk)
_stage("done")
+25 -22
View File
@@ -13,6 +13,17 @@ import aiohttp
from ..utils.config import get_api_key_or_raise, get_async_api_base_url, NETWORK_ROUTE_OPTIONS, get_base_url_by_route
from ..utils.image_utils import tensor_to_pil, encode_image_to_base64
from ..utils.http_error import async_request_with_retry
from ..utils.video_task import (
check_interrupt,
extract_error_message,
extract_progress,
extract_status,
extract_video_url,
interruptible_sleep,
is_failure_status,
is_success_status,
run_with_interrupt,
)
try:
from comfy_api.latest import InputImpl
@@ -185,11 +196,13 @@ class K3VideoFirstLast:
async with aiohttp.ClientSession(connector=connector) as session:
# 1. 提交
check_interrupt()
_stage("submitting")
create_url = f"{base_url}{_ENDPOINT_CREATE}"
resp = await async_request_with_retry(
resp = await run_with_interrupt(async_request_with_retry(
session, "POST", create_url, json=body, headers=headers, prefix="K3 首尾帧提交: "
)
))
check_interrupt()
text = await resp.text()
create_resp = json.loads(text)
@@ -208,6 +221,7 @@ class K3VideoFirstLast:
video_url = None
while True:
check_interrupt()
async with session.get(status_url, headers=headers) as resp:
text = await resp.text()
if resp.status != 200:
@@ -220,39 +234,27 @@ class K3VideoFirstLast:
sr = json.loads(text)
data = sr.get("data", sr)
status = (data.get("status") or sr.get("status") or "").lower()
status = extract_status(sr)
pct_raw = data.get("progress", 0)
try:
pct = int(str(pct_raw).rstrip("%").strip())
except (ValueError, AttributeError):
pct = 0
pct = extract_progress(sr)
print(f"[K3 首尾帧] 生成中 {pct}%")
_progress(pct)
if status in ("success", "completed", "done", "finished", "succeed"):
video_url = (
data.get("video_url")
or data.get("result_url")
or data.get("url")
or (data.get("result", {}) or {}).get("url")
or sr.get("video_url")
or sr.get("url")
)
if is_success_status(status):
video_url = extract_video_url(sr)
break
if status in ("failed", "fail"):
err_info = data.get("error") or sr.get("error") or {}
err_msg = (err_info.get("message", "未知错误")
if isinstance(err_info, dict) else str(err_info))
if is_failure_status(status, sr):
err_msg = extract_error_message(sr)
raise RuntimeError(f"K3 首尾帧生成失败:{err_msg}")
await asyncio.sleep(interval)
await interruptible_sleep(interval)
interval = min(interval * 1.5, _POLL_MAX)
if not video_url:
raise RuntimeError(f"API 未返回视频 URL,响应:{sr}")
# 3. 下载
check_interrupt()
_stage("downloading")
async with session.get(video_url, allow_redirects=True) as resp:
if resp.status != 200:
@@ -260,6 +262,7 @@ class K3VideoFirstLast:
os.close(tmp_fd)
with open(save_path, "wb") as f:
async for chunk in resp.content.iter_chunked(8192):
check_interrupt()
f.write(chunk)
_stage("done")
+25 -22
View File
@@ -13,6 +13,17 @@ import aiohttp
from ..utils.config import get_api_key_or_raise, get_api_base_url, NETWORK_ROUTE_OPTIONS, get_base_url_by_route
from ..utils.image_utils import tensor_to_pil, encode_image_to_base64
from ..utils.http_error import async_request_with_retry
from ..utils.video_task import (
check_interrupt,
extract_error_message,
extract_progress,
extract_status,
extract_video_url,
interruptible_sleep,
is_failure_status,
is_success_status,
run_with_interrupt,
)
try:
from comfy_api.latest import InputImpl
@@ -154,11 +165,13 @@ class KVideoFirstLast:
async with aiohttp.ClientSession(connector=connector) as session:
# 1. 提交
check_interrupt()
_stage("submitting")
create_url = f"{base_url}{_ENDPOINT_CREATE}"
resp = await async_request_with_retry(
resp = await run_with_interrupt(async_request_with_retry(
session, "POST", create_url, json=body, headers=headers, prefix="K26 提交: "
)
))
check_interrupt()
text = await resp.text()
create_resp = json.loads(text)
@@ -177,6 +190,7 @@ class KVideoFirstLast:
video_url = None
while True:
check_interrupt()
async with session.get(status_url, headers=headers) as resp:
text = await resp.text()
if resp.status != 200:
@@ -189,40 +203,28 @@ class KVideoFirstLast:
sr = json.loads(text)
data = sr.get("data", sr)
status = (data.get("status") or sr.get("status") or "").lower()
status = extract_status(sr)
pct_raw = data.get("progress", 0)
try:
pct = int(str(pct_raw).rstrip("%").strip())
except (ValueError, AttributeError):
pct = 0
pct = extract_progress(sr)
print(f"[K26 图生视频] 生成中 {pct}%")
_progress(pct)
if status in ("success", "completed", "done", "finished", "succeed"):
if is_success_status(status):
# 提取视频 URL
video_url = (
data.get("video_url")
or data.get("result_url")
or data.get("url")
or (data.get("result", {}) or {}).get("url")
or sr.get("video_url")
or sr.get("url")
)
video_url = extract_video_url(sr)
break
if status in ("failed", "fail"):
err_info = data.get("error") or sr.get("error") or {}
err_msg = (err_info.get("message", "未知错误")
if isinstance(err_info, dict) else str(err_info))
if is_failure_status(status, sr):
err_msg = extract_error_message(sr)
raise RuntimeError(f"K26 生成失败:{err_msg}")
await asyncio.sleep(interval)
await interruptible_sleep(interval)
interval = min(interval * 1.5, _POLL_MAX)
if not video_url:
raise RuntimeError(f"API 未返回视频 URL,响应:{sr}")
# 3. 下载
check_interrupt()
_stage("downloading")
async with session.get(video_url, allow_redirects=True) as resp:
if resp.status != 200:
@@ -230,6 +232,7 @@ class KVideoFirstLast:
os.close(tmp_fd)
with open(save_path, "wb") as f:
async for chunk in resp.content.iter_chunked(8192):
check_interrupt()
f.write(chunk)
_stage("done")
+25 -23
View File
@@ -14,6 +14,17 @@ import aiohttp
from ..utils.config import get_api_key_or_raise, get_api_base_url, NETWORK_ROUTE_OPTIONS, get_base_url_by_route
from ..utils.image_utils import tensor_to_pil, encode_image_to_base64
from ..utils.http_error import async_request_with_retry
from ..utils.video_task import (
check_interrupt,
extract_error_message,
extract_progress,
extract_status,
extract_video_url,
interruptible_sleep,
is_failure_status,
is_success_status,
run_with_interrupt,
)
try:
from comfy_api.latest import InputImpl
@@ -150,11 +161,13 @@ class KVideoImage2Video:
async with aiohttp.ClientSession(connector=connector) as session:
# 1. 提交
check_interrupt()
_stage("submitting")
create_url = f"{base_url}{_ENDPOINT_CREATE}"
resp = await async_request_with_retry(
resp = await run_with_interrupt(async_request_with_retry(
session, "POST", create_url, json=body, headers=headers, prefix="K26 图生视频提交: "
)
))
check_interrupt()
sr = await resp.json()
task_id = sr.get("task_id") or sr.get("id")
@@ -169,8 +182,9 @@ class KVideoImage2Video:
video_url = None
while True:
await asyncio.sleep(interval)
await interruptible_sleep(interval)
check_interrupt()
async with session.get(status_url, headers=headers) as resp:
if resp.status != 200:
err_text = await resp.text()
@@ -178,40 +192,27 @@ class KVideoImage2Video:
sr = await resp.json()
data = sr.get("data", {}) or {}
status = (sr.get("status") or data.get("status") or "").lower()
status = extract_status(sr)
pct_raw = str(data.get("progress", 0)).strip().rstrip('%')
try:
pct = max(0, min(100, int(float(pct_raw))))
except (ValueError, TypeError):
pct = 0
pct = extract_progress(sr)
print(f"[K26 图生视频] 生成中 {pct}%")
_progress(pct)
if status in ("success", "completed", "done", "finished", "succeed"):
if is_success_status(status):
# 提取视频 URL
video_url = (
data.get("video_url")
or data.get("result_url")
or data.get("url")
or (data.get("result", {}) or {}).get("url")
or sr.get("video_url")
or sr.get("url")
)
video_url = extract_video_url(sr)
break
if status in ("failed", "fail"):
err_info = data.get("error") or sr.get("error") or {}
err_msg = (err_info.get("message", "未知错误")
if isinstance(err_info, dict) else str(err_info))
if is_failure_status(status, sr):
err_msg = extract_error_message(sr)
raise RuntimeError(f"K26 生成失败:{err_msg}")
await asyncio.sleep(interval)
interval = min(interval * 1.5, _POLL_MAX)
if not video_url:
raise RuntimeError(f"API 未返回视频 URL,响应:{sr}")
# 3. 下载
check_interrupt()
_stage("downloading")
async with session.get(video_url, allow_redirects=True) as resp:
if resp.status != 200:
@@ -219,6 +220,7 @@ class KVideoImage2Video:
os.close(tmp_fd)
with open(save_path, "wb") as f:
async for chunk in resp.content.iter_chunked(8192):
check_interrupt()
f.write(chunk)
_stage("done")
+3 -2
View File
@@ -20,7 +20,7 @@ from .batch_images_o1key import BatchImagesO1key
from .seedance_video import Seedance, SeedanceMultiModal
from .nano_banana_v2 import NanoBananaV2, NanoBananaV2Batch, AsyncImageGenerator, BatchAsyncImageGenerator
from .doubao_image import DoubaoImage
from .gpt_image import O1keyGPTImage
from .gpt_image import O1keyGPTImage, O1keyGPTImageBatch
from .grok_image import O1keyGrokImage
from .K_video_firstlast import KVideoFirstLast
from .K_video_image2video import KVideoImage2Video
@@ -31,5 +31,6 @@ from .save_image_format import SaveImageFormat
from .save_psd import O1keySavePSD
from .remove_bg import O1keyRemoveBackground
from .color_remove_bg import O1keyColorRemoveBG
from .grid_splitter import O1keyGridSplitter
__all__ = ['NanoBananaV2', 'NanoBananaV2Batch', 'NanoBanana', 'BatchNanoBananaPro', 'GoogleGemini', 'LoadFile', 'ImageStitchPro', 'BatchCleanMetadata', 'VideoPreview', 'KlingVideo', 'KlingFirstLastFrame', 'KlingMotionControlTest', 'AspectRatioPreset', 'GoogleVeo', 'FluxImageEdit', 'UniversalLLMChat', 'BatchImagesO1key', 'Seedance', 'SeedanceMultiModal', 'StreamPreview', 'DoubaoImage', 'O1keyGPTImage', 'O1keyGrokImage', 'KVideoFirstLast', 'KVideoImage2Video', 'K3Video', 'K3VideoFirstLast', 'K3MotionControl', 'K3MotionVideoCheck', 'AsyncImageGenerator', 'BatchAsyncImageGenerator', 'SaveImageFormat', 'O1keySavePSD', 'O1keyRemoveBackground', 'O1keyColorRemoveBG']
__all__ = ['NanoBananaV2', 'NanoBananaV2Batch', 'NanoBanana', 'BatchNanoBananaPro', 'GoogleGemini', 'LoadFile', 'ImageStitchPro', 'BatchCleanMetadata', 'VideoPreview', 'KlingVideo', 'KlingFirstLastFrame', 'KlingMotionControlTest', 'AspectRatioPreset', 'GoogleVeo', 'FluxImageEdit', 'UniversalLLMChat', 'BatchImagesO1key', 'Seedance', 'SeedanceMultiModal', 'StreamPreview', 'DoubaoImage', 'O1keyGPTImage', 'O1keyGPTImageBatch', 'O1keyGrokImage', 'KVideoFirstLast', 'KVideoImage2Video', 'K3Video', 'K3VideoFirstLast', 'K3MotionControl', 'K3MotionVideoCheck', 'AsyncImageGenerator', 'BatchAsyncImageGenerator', 'SaveImageFormat', 'O1keySavePSD', 'O1keyRemoveBackground', 'O1keyColorRemoveBG', 'O1keyGridSplitter']
+26 -22
View File
@@ -20,7 +20,7 @@ from PIL import Image
import torch
import numpy as np
from ..utils.image_utils import tensor_to_pil, pil_to_tensor, parse_batch_prompts, encode_image_to_base64, encode_image_to_base64_limited
from ..utils.image_utils import tensor_to_pil, pil_to_tensor, parse_batch_prompts, encode_images_for_request_body_limit
from ..utils.file_utils import (
ImageInfo,
load_images_from_folder,
@@ -95,22 +95,6 @@ def _build_request_body(
images: Optional[List[Image.Image]] = None,
enable_grounding: bool = False,
) -> dict:
content_parts = [{"type": "text", "text": prompt}]
if images:
for img in images:
b64 = encode_image_to_base64_limited(img, format="PNG")
content_parts.append({
"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{b64}"}
})
body = {
"model": model,
"stream": True,
"messages": [{"role": "user", "content": content_parts}],
}
google_config = {
"image_config": {
"image_size": resolution,
@@ -118,12 +102,32 @@ def _build_request_body(
}
if aspect_ratio and aspect_ratio != "智能":
google_config["image_config"]["aspect_ratio"] = aspect_ratio
body["extra_body"] = {"google": google_config}
if enable_grounding:
body["extra_body"]["google_search"] = True
def _make_body(encoded_images: Optional[List[tuple]] = None) -> dict:
content_parts = [{"type": "text", "text": prompt}]
if encoded_images:
for mime_type, b64 in encoded_images:
content_parts.append({
"type": "image_url",
"image_url": {"url": f"data:{mime_type};base64,{b64}"}
})
return body
body = {
"model": model,
"stream": True,
"messages": [{"role": "user", "content": content_parts}],
"extra_body": {"google": google_config},
}
if enable_grounding:
body["extra_body"]["google_search"] = True
return body
encoded_images = None
if images:
encoded_images = encode_images_for_request_body_limit(images, _make_body)
return _make_body(encoded_images)
async def _generate_single_openai(
@@ -1159,4 +1163,4 @@ class BatchNanoBananaPro:
# 最终内存清理
import gc
gc.collect()
print(f"BatchNanoBananaPro: 最终内存清理完成")
print(f"BatchNanoBananaPro: 最终内存清理完成")
+478 -1
View File
@@ -3,10 +3,23 @@ o1key GPT Image 节点
支持 gpt-image-1 / gpt-image-1.5 模型的文生图、图生图、图像编辑(带蒙版)
"""
import os
import time
from typing import List, Optional, Tuple
from PIL import Image
from ..clients.gpt_image_client import GptImageClient
from ..utils.image_utils import parse_batch_prompts
from ..utils.image_utils import parse_batch_prompts, pil_to_tensor, tensor_to_pil
from ..utils.config import NETWORK_ROUTE_OPTIONS, get_base_url_by_route
from ..utils.file_utils import (
ImageInfo,
generate_timestamp_filename,
load_images_from_folder,
pair_images_by_name,
pair_images_cartesian,
save_image,
)
try:
from comfy.model_management import processing_interrupted, InterruptProcessingException
@@ -16,6 +29,18 @@ except ImportError:
processing_interrupted = lambda: False
InterruptProcessingException = RuntimeError
try:
from comfy.utils import ProgressBar
_PROGRESS_BAR_AVAILABLE = True
except ImportError:
_PROGRESS_BAR_AVAILABLE = False
try:
import folder_paths
_FOLDER_PATHS_AVAILABLE = True
except ImportError:
_FOLDER_PATHS_AVAILABLE = False
class O1keyGPTImage:
"""
@@ -270,3 +295,455 @@ class O1keyGPTImage:
print(f"[o1key GPT Image] {balance_info}")
except Exception:
pass
class O1keyGPTImageBatch:
"""
o1key GPT Image 批量节点
复用 BatchNanoBananaPro 的批量思路:
- 从文件夹批量加载图片
- 按文件名同名 / 1*N / 不配对 三种模式创建任务
- 可追加节点手动输入参考图
- prompt 支持用独占一行 --- 展开为多提示词任务
- 每个任务调用 GPT Image 客户端并保存到磁盘
"""
PAIRING_MODES = ["按相同图片命名", "1*N", "不配对"]
IMAGE_FORMATS = ["原始", "JPEG", "PNG", "WebP"]
MODEL_OPTIONS = ["gpt-image-2-按量", "gpt-image-2-次卡"]
QUALITY_OPTIONS = ["", "", "", "自动"]
RESOLUTION_OPTIONS = [
"智能",
"1024x10241K 正方形 1:1",
"1536x10241K 横版 3:2",
"1024x15361K 竖版 2:3",
"1360x10241K 横版 4:3",
"1024x13601K 竖版 3:4",
"1824x10241K 横版 16:9",
"1024x18241K 竖版 9:16",
"2048x20482K 正方形 1:1",
"3072x20482K 横版 3:2",
"2048x30722K 竖版 2:3",
"2736x20482K 横版 4:3",
"2048x27362K 竖版 3:4",
"3648x20482K 横版 16:9",
"2048x36482K 竖版 9:16",
"2880x28804K 正方形 1:1",
"3504x23364K 横版 3:2",
"2336x35044K 竖版 2:3",
"3264x24484K 横版 4:3",
"2448x32644K 竖版 3:4",
"3840x21604K 横版 16:9",
"2160x38404K 竖版 9:16",
]
@classmethod
def INPUT_TYPES(cls):
optional_inputs = {}
for image_index in range(1, 10):
optional_inputs[f"参考图{image_index}"] = ("IMAGE", {
"tooltip": "追加到每个批量任务末尾的固定参考图。",
})
optional_inputs["遮罩"] = ("MASK", {
"tooltip": "可选蒙版,会应用到每个任务的第一张参考图;请确保尺寸一致。",
})
optional_inputs["图片配对模式"] = (cls.PAIRING_MODES, {
"default": "不配对",
"tooltip": "文件夹图片的组合方式;手动参考图只追加,不参与配对。",
})
return {
"required": {
"prompt": ("STRING", {
"default": "",
"multiline": True,
"tooltip": "提示词;可用独占一行的 --- 分隔多条批量提示词。",
}),
"模型": (cls.MODEL_OPTIONS, {
"default": "gpt-image-2-次卡",
}),
"网络": (NETWORK_ROUTE_OPTIONS, {
"default": "全球加速",
}),
"分辨率": (cls.RESOLUTION_OPTIONS, {
"default": "智能",
}),
"生图数量": ("INT", {
"default": 1,
"min": 1,
"max": 8,
"step": 1,
"display": "number",
}),
"质量": (cls.QUALITY_OPTIONS, {
"default": "自动",
}),
"seed": ("INT", {
"default": 0,
"min": 0,
"max": 2**31 - 1,
"step": 1,
"display": "number",
"control_after_generate": True,
}),
"图片格式": (cls.IMAGE_FORMATS, {
"default": "原始",
}),
"文件夹1": ("STRING", {
"default": "",
"multiline": False,
}),
"文件夹2": ("STRING", {
"default": "",
"multiline": False,
}),
"文件夹3": ("STRING", {
"default": "",
"multiline": False,
}),
"文件夹4": ("STRING", {
"default": "",
"multiline": False,
}),
"文件夹5": ("STRING", {
"default": "",
"multiline": False,
}),
"保存路径": ("STRING", {
"default": "",
"multiline": False,
"tooltip": "为空时优先使用 ComfyUI 默认 output 目录。",
}),
},
"optional": optional_inputs,
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("IMAGE",)
FUNCTION = "process_batch"
CATEGORY = "o1key/image"
OUTPUT_NODE = False
def _load_folders(self, folders: List[str]) -> List[List[ImageInfo]]:
image_lists = []
for folder_index, folder in enumerate(folders, 1):
if not folder or not folder.strip():
continue
try:
loaded_images = load_images_from_folder(folder)
if loaded_images:
image_lists.append(loaded_images)
except ValueError as error:
print(f"[o1key GPT Image Batch] 文件夹{folder_index} 加载失败 - {error}")
return image_lists
def _create_pairs(
self,
image_lists: List[List[ImageInfo]],
pairing_mode: str,
manual_images: Optional[List[ImageInfo]] = None,
) -> List[Tuple[ImageInfo, ...]]:
if pairing_mode == "不配对":
if len(image_lists) > 1:
raise ValueError("「不配对」模式只支持单个文件夹,请清空其他文件夹路径")
if image_lists and manual_images:
return [
(folder_image,) + tuple(manual_images)
for folder_image in image_lists[0]
]
if image_lists:
return [(folder_image,) for folder_image in image_lists[0]]
return []
if not image_lists:
return []
if len(image_lists) == 1:
base_pairs = [(folder_image,) for folder_image in image_lists[0]]
elif pairing_mode == "按相同图片命名":
base_pairs = list(pair_images_by_name(*image_lists))
else:
base_pairs = list(pair_images_cartesian(*image_lists))
if manual_images:
manual_tuple = tuple(manual_images)
base_pairs = [pair + manual_tuple for pair in base_pairs]
return base_pairs
def _collect_manual_images(self, kwargs) -> List[ImageInfo]:
manual_images = []
for image_index in range(1, 10):
key = f"参考图{image_index}"
if key not in kwargs or kwargs[key] is None:
continue
for tensor_index, image in enumerate(tensor_to_pil(kwargs[key])):
manual_images.append(ImageInfo(
image=image,
filename=f"manual_{image_index}_{tensor_index}",
extension=".png",
source_path="",
))
return manual_images
@staticmethod
def _pair_to_tensors(pair: Tuple[ImageInfo, ...]) -> List:
return [pil_to_tensor([image_info.image]) for image_info in pair]
@staticmethod
def _resolve_size(分辨率: str) -> str:
return "auto" if 分辨率 == "智能" else 分辨率.split("")[0].strip()
@staticmethod
def _resolve_model(模型: str) -> str:
model_map = {
"gpt-image-2-次卡": "gpt-image-2-c",
"gpt-image-2-按量": "gpt-image-2",
}
return model_map.get(模型, 模型)
@staticmethod
def _resolve_quality(质量: str) -> str:
quality_map = {"": "high", "": "medium", "": "low", "自动": "auto"}
return quality_map.get(质量, "auto")
@staticmethod
def _ensure_output_folder(保存路径: str) -> str:
output_folder = (保存路径 or "").strip()
if not output_folder and _FOLDER_PATHS_AVAILABLE:
output_folder = folder_paths.get_output_directory()
print(f"[o1key GPT Image Batch] 未设置保存路径,使用 ComfyUI 默认 output 目录: {output_folder}")
if not output_folder:
raise ValueError("未设置保存路径,且当前环境无法获取 ComfyUI 默认 output 目录")
os.makedirs(output_folder, exist_ok=True)
test_path = os.path.join(output_folder, ".write_test")
with open(test_path, "w", encoding="utf-8") as test_file:
test_file.write("test")
os.remove(test_path)
return output_folder
@staticmethod
def _save_images(
images: List[Image.Image],
output_folder: str,
image_format: str,
base_filename: Optional[str] = None,
) -> List[str]:
format_ext_map = {"JPEG": ".jpg", "PNG": ".png", "WebP": ".webp"}
save_ext = format_ext_map.get(image_format, ".png")
saved_files = []
for image in images:
if base_filename:
counter = 0
while True:
suffix = "" if counter == 0 else f"+{counter}"
filename = f"{base_filename}{suffix}{save_ext}"
output_path = os.path.join(output_folder, filename)
if not os.path.exists(output_path):
break
counter += 1
else:
output_path = generate_timestamp_filename(
output_folder=output_folder,
extension=save_ext,
)
if image_format == "JPEG":
if image.mode != "RGB":
image = image.convert("RGB")
image.save(output_path, quality=100)
elif image_format == "WebP":
image.save(output_path, lossless=True)
else:
save_image(image, output_path)
saved_files.append(output_path)
return saved_files
def process_batch(
self,
prompt: str,
模型: str,
网络: str,
分辨率: str,
生图数量: int,
质量: str,
seed: int,
图片格式: str,
文件夹1: str,
文件夹2: str,
文件夹3: str,
文件夹4: str,
文件夹5: str,
保存路径: str = "",
图片配对模式: str = "不配对",
遮罩=None,
**kwargs,
):
start_time = time.time()
client = None
try:
if not prompt or not prompt.strip():
raise ValueError("提示词不能为空")
folders = [文件夹1, 文件夹2, 文件夹3, 文件夹4, 文件夹5]
if not any(folder and folder.strip() for folder in folders):
raise ValueError("请至少填写一个文件夹路径,该节点专为批量文件夹处理设计")
image_lists = self._load_folders(folders)
total_folder_images = sum(len(image_list) for image_list in image_lists)
if total_folder_images == 0:
raise ValueError("文件夹中未找到任何图片,请检查文件夹路径是否正确")
manual_images = self._collect_manual_images(kwargs)
pairs = self._create_pairs(
image_lists=image_lists,
pairing_mode=图片配对模式,
manual_images=manual_images if manual_images else None,
)
if not pairs:
raise ValueError("配对结果为空,请检查输入")
batch_prompts = parse_batch_prompts(prompt)
prompts_per_task = None
if batch_prompts:
expanded_pairs = []
expanded_prompts = []
for pair in pairs:
for batch_prompt in batch_prompts:
expanded_pairs.append(pair)
expanded_prompts.append(batch_prompt)
pairs = expanded_pairs
prompts_per_task = expanded_prompts
total_tasks = len(pairs)
if batch_prompts:
print(
f"[o1key GPT Image Batch] 批量任务 | {图片配对模式} × "
f"{len(batch_prompts)} 个提示词 | 共 {total_tasks} 任务"
)
else:
print(f"[o1key GPT Image Batch] 批量任务 | {图片配对模式} | 共 {total_tasks} 任务")
output_folder = self._ensure_output_folder(保存路径)
size = self._resolve_size(分辨率)
model = self._resolve_model(模型)
quality = self._resolve_quality(质量)
client = GptImageClient()
client.base_url = get_base_url_by_route(网络)
progress_bar = ProgressBar(total_tasks) if _PROGRESS_BAR_AVAILABLE else None
results = []
all_saved_files = []
for task_index, pair in enumerate(pairs, 1):
if _INTERRUPT_AVAILABLE and processing_interrupted():
print("[o1key GPT Image Batch] 用户取消,已中断批量生成")
raise InterruptProcessingException()
task_prompt = prompts_per_task[task_index - 1] if prompts_per_task else prompt
base_filename = pair[0].filename if pair else None
result = {
"task_index": task_index,
"success": False,
"generated_count": 0,
"saved_files": [],
"error": None,
}
try:
pil_images = client.run_sync(
prompt=task_prompt,
model=model,
quality=quality,
size=size,
n=生图数量,
seed=seed,
image_tensor=self._pair_to_tensors(pair),
mask_tensor=遮罩,
)
saved_files = self._save_images(
images=pil_images,
output_folder=output_folder,
image_format=图片格式,
base_filename=base_filename,
)
result["success"] = bool(pil_images)
result["generated_count"] = len(pil_images)
result["saved_files"] = saved_files
all_saved_files.extend(saved_files)
print(f"[o1key GPT Image Batch] [{task_index}/{total_tasks}] ✓ {base_filename or 'task'}")
except InterruptProcessingException:
raise
except Exception as error:
error_msg = str(error).split("\n")[0]
result["error"] = error_msg
print(f"[o1key GPT Image Batch] [{task_index}/{total_tasks}] ❌ {base_filename or 'task'}{error_msg}")
results.append(result)
if progress_bar is not None:
progress_bar.update(1)
success_count = sum(1 for result in results if result.get("success", False))
total_generated = sum(result.get("generated_count", 0) for result in results)
if success_count == 0:
raise RuntimeError("所有批量任务均生成失败,无可用图像输出")
output_images = []
for file_path in all_saved_files[-10:]:
try:
loaded_image = Image.open(file_path)
loaded_image.load()
output_images.append(loaded_image)
except Exception as error:
print(f"[o1key GPT Image Batch] 无法加载输出图片 {file_path} - {error}")
if not output_images:
output_images = [Image.new("RGBA", (512, 512), (128, 128, 128, 255))]
output_tensor = GptImageClient._pil_list_to_tensor(output_images)
elapsed = time.time() - start_time
print("=" * 60)
print(
f"[o1key GPT Image Batch] 完成!耗时 {elapsed:.1f}s | "
f"成功 {success_count}/{total_tasks} | 生成 {total_generated}"
)
print(f"[o1key GPT Image Batch] 保存路径: {output_folder}")
if all_saved_files:
print(f"[o1key GPT Image Batch] 最新保存文件: {all_saved_files[-1]}")
failed_results = [result for result in results if not result.get("success", False)]
if failed_results:
print(f"[o1key GPT Image Batch] 失败任务: {len(failed_results)}")
for failed_result in failed_results[:3]:
print(
f" - #{failed_result.get('task_index')}: "
f"{failed_result.get('error', '未知错误')}"
)
return (output_tensor,)
except ValueError as error:
if str(error) == "未授权!":
print("[o1key GPT Image Batch] 请联系作者授权后方可使用!")
raise ValueError("未授权!") from None
raise ValueError(str(error)) from None
except RuntimeError as error:
raise RuntimeError(str(error)) from None
finally:
if client is not None:
try:
balance_data = client.query_balance_sync()
balance_info = client.format_balance_info(balance_data)
print(f"[o1key GPT Image Batch] {balance_info}")
except Exception:
pass
+398
View File
@@ -0,0 +1,398 @@
"""
Merged grid image splitter.
This node is designed for AI-generated contact sheets such as 3x3 or 2x3
grids. Auto mode scores common layouts by looking for strong seams or flat
separator bands near the expected grid lines, then crops each cell.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import List, Sequence, Tuple
import numpy as np
import torch
from PIL import Image
from ..utils.image_utils import pil_to_tensor, tensor_to_pil
_AUTO_LAYOUTS: Sequence[Tuple[int, int]] = (
(3, 3),
(2, 3),
(3, 2),
(2, 2),
(1, 2),
(2, 1),
(1, 3),
(3, 1),
(4, 4),
(3, 4),
(4, 3),
)
_LAYOUTS = [
"auto",
"1x2",
"2x1",
"1x3",
"3x1",
"2x2",
"2x3",
"3x2",
"3x3",
"3x4",
"4x3",
"4x4",
"custom",
]
@dataclass(frozen=True)
class _AxisCut:
seam: int
span_start: int
span_end: int
score: float
@dataclass(frozen=True)
class _AxisPlan:
intervals: List[Tuple[int, int]]
cuts: List[_AxisCut]
score: float
def _to_float_array(image: Image.Image) -> np.ndarray:
if image.mode != "RGB":
image = image.convert("RGB")
return np.asarray(image).astype(np.float32) / 255.0
def _axis_texture(arr: np.ndarray, axis: str) -> np.ndarray:
if axis == "x":
profile = arr.std(axis=(0, 2))
else:
profile = arr.std(axis=(1, 2))
high = np.percentile(profile, 95) + 1e-6
return np.clip(profile / high, 0.0, 1.0)
def _axis_edge(arr: np.ndarray, axis: str) -> np.ndarray:
if axis == "x":
diff = np.abs(np.diff(arr, axis=1)).mean(axis=(0, 2))
length = arr.shape[1]
else:
diff = np.abs(np.diff(arr, axis=0)).mean(axis=(1, 2))
length = arr.shape[0]
padded = np.zeros(length, dtype=np.float32)
if diff.size:
padded[1:] = diff
high = np.percentile(padded, 95) + 1e-6
return np.clip(padded / high, 0.0, 1.5)
def _smooth(profile: np.ndarray, radius: int = 2) -> np.ndarray:
if radius <= 0 or profile.size < radius * 2 + 1:
return profile
kernel = np.ones(radius * 2 + 1, dtype=np.float32) / float(radius * 2 + 1)
return np.convolve(profile, kernel, mode="same")
def _separator_span(
texture: np.ndarray,
seam: int,
search_px: int,
min_separator_px: int,
) -> Tuple[int, int]:
length = texture.size
if length <= 1:
return 0, length
limit = max(1, min(search_px, length // 8))
threshold = max(0.08, min(0.28, float(np.percentile(texture, 12)) * 1.8))
left = seam
while left > 0 and seam - left < limit and texture[left - 1] <= threshold:
left -= 1
right = seam
while right < length and right - seam < limit and texture[right] <= threshold:
right += 1
if right - left >= max(1, min_separator_px):
return left, right
return seam, seam
def _edge_trim(texture: np.ndarray, search_px: int, min_cell: int) -> Tuple[int, int]:
length = texture.size
if length <= 2:
return 0, length
max_trim = max(0, min(search_px * 2, min_cell // 3, length // 6))
if max_trim <= 0:
return 0, length
threshold = max(0.08, min(0.24, float(np.percentile(texture, 12)) * 1.6))
start = 0
while start < max_trim and texture[start] <= threshold:
start += 1
end = length
while length - end < max_trim and end > start + min_cell and texture[end - 1] <= threshold:
end -= 1
return start, end
def _axis_plan(
arr: np.ndarray,
cells: int,
axis: str,
search_px: int,
crop_separators: bool,
trim_outer: bool,
min_separator_px: int,
) -> _AxisPlan:
length = arr.shape[1] if axis == "x" else arr.shape[0]
if cells <= 1:
return _AxisPlan(intervals=[(0, length)], cuts=[], score=0.0)
raw_texture = _axis_texture(arr, axis)
raw_edge = _axis_edge(arr, axis)
texture = _smooth(raw_texture, radius=2)
edge = _smooth(raw_edge, radius=1)
evidence = np.maximum(edge, (1.0 - texture) * 0.75)
exact_evidence = np.maximum(raw_edge, (1.0 - raw_texture) * 0.75)
cuts: List[_AxisCut] = []
scores: List[float] = []
for idx in range(1, cells):
expected = round(length * idx / cells)
start = max(1, expected - search_px)
end = min(length - 1, expected + search_px)
if start >= end:
seam = expected
score = 0.0
else:
window = evidence[start:end + 1]
offset = int(window.argmax())
coarse = start + offset
fine_start = max(start, coarse - 2)
fine_end = min(end, coarse + 2)
fine_window = exact_evidence[fine_start:fine_end + 1]
seam = fine_start + int(fine_window.argmax())
score = float(window[offset])
span_start, span_end = _separator_span(
raw_texture,
seam,
search_px=search_px,
min_separator_px=min_separator_px,
)
cuts.append(_AxisCut(seam=seam, span_start=span_start, span_end=span_end, score=score))
scores.append(score)
min_cell = max(1, length // cells)
outer_start, outer_end = _edge_trim(raw_texture, search_px, min_cell) if trim_outer else (0, length)
intervals: List[Tuple[int, int]] = []
cursor = outer_start
for cut in cuts:
split_start = cut.span_start if crop_separators else cut.seam
split_end = cut.span_end if crop_separators else cut.seam
intervals.append((cursor, split_start))
cursor = split_end
intervals.append((cursor, outer_end))
cleaned: List[Tuple[int, int]] = []
for start, end in intervals:
start = max(0, min(length - 1, int(start)))
end = max(start + 1, min(length, int(end)))
cleaned.append((start, end))
return _AxisPlan(
intervals=cleaned,
cuts=cuts,
score=float(np.mean(scores)) if scores else 0.0,
)
def _parse_layout(layout: str, custom_rows: int, custom_cols: int) -> Tuple[int, int]:
if layout == "custom":
return max(1, int(custom_rows)), max(1, int(custom_cols))
rows_text, cols_text = layout.split("x", 1)
return int(rows_text), int(cols_text)
def _fallback_layout(width: int, height: int) -> Tuple[int, int]:
aspect = width / max(1, height)
if 0.82 <= aspect <= 1.22:
return 3, 3
if aspect > 1.22:
return 2, 3
return 3, 2
def _choose_auto_layout(
arr: np.ndarray,
search_px: int,
crop_separators: bool,
trim_outer: bool,
min_separator_px: int,
) -> Tuple[int, int, _AxisPlan, _AxisPlan, float, bool]:
height, width = arr.shape[:2]
best = None
for rows, cols in _AUTO_LAYOUTS:
x_plan = _axis_plan(arr, cols, "x", search_px, crop_separators, trim_outer, min_separator_px)
y_plan = _axis_plan(arr, rows, "y", search_px, crop_separators, trim_outer, min_separator_px)
score = (x_plan.score + y_plan.score) / 2.0
# Prefer common 3x3 / 2x3 / 3x2 layouts when the image gives weak signals.
if (rows, cols) in ((3, 3), (2, 3), (3, 2)):
score += 0.025
if best is None or score > best[0]:
best = (score, rows, cols, x_plan, y_plan)
assert best is not None
score, rows, cols, x_plan, y_plan = best
confident = score >= 0.22
if confident:
return rows, cols, x_plan, y_plan, score, True
rows, cols = _fallback_layout(width, height)
x_plan = _axis_plan(arr, cols, "x", search_px, crop_separators, trim_outer, min_separator_px)
y_plan = _axis_plan(arr, rows, "y", search_px, crop_separators, trim_outer, min_separator_px)
return rows, cols, x_plan, y_plan, score, False
def _normalize_sizes(crops: List[Image.Image]) -> List[Image.Image]:
min_w = min(crop.width for crop in crops)
min_h = min(crop.height for crop in crops)
normalized = []
for crop in crops:
left = max(0, (crop.width - min_w) // 2)
top = max(0, (crop.height - min_h) // 2)
normalized.append(crop.crop((left, top, left + min_w, top + min_h)))
return normalized
def _split_one(
image: Image.Image,
layout: str,
custom_rows: int,
custom_cols: int,
search_px: int,
crop_separators: bool,
trim_outer: bool,
min_separator_px: int,
) -> Tuple[List[Image.Image], str]:
arr = _to_float_array(image)
if layout == "auto":
rows, cols, x_plan, y_plan, confidence, confident = _choose_auto_layout(
arr,
search_px=search_px,
crop_separators=crop_separators,
trim_outer=trim_outer,
min_separator_px=min_separator_px,
)
mode_note = "auto" if confident else "auto-low-confidence-fallback"
else:
rows, cols = _parse_layout(layout, custom_rows, custom_cols)
x_plan = _axis_plan(arr, cols, "x", search_px, crop_separators, trim_outer, min_separator_px)
y_plan = _axis_plan(arr, rows, "y", search_px, crop_separators, trim_outer, min_separator_px)
confidence = (x_plan.score + y_plan.score) / 2.0
mode_note = "manual"
crops: List[Image.Image] = []
for y0, y1 in y_plan.intervals:
for x0, x1 in x_plan.intervals:
crops.append(image.crop((x0, y0, x1, y1)))
crops = _normalize_sizes(crops)
info = (
f"{mode_note}: {rows}x{cols}, cells={len(crops)}, "
f"confidence={confidence:.3f}, "
f"x={x_plan.intervals}, y={y_plan.intervals}"
)
return crops, info
class O1keyGridSplitter:
"""Split AI-generated grid/contact-sheet images into individual cells."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"图像": ("IMAGE",),
"布局": (_LAYOUTS, {"default": "auto"}),
"自定义行数": ("INT", {"default": 3, "min": 1, "max": 12, "step": 1}),
"自定义列数": ("INT", {"default": 3, "min": 1, "max": 12, "step": 1}),
"搜索范围px": ("INT", {"default": 32, "min": 0, "max": 256, "step": 1}),
"裁掉分隔线": ("BOOLEAN", {"default": True}),
"裁掉外边距": ("BOOLEAN", {"default": True}),
"最小分隔线px": ("INT", {"default": 2, "min": 0, "max": 64, "step": 1}),
"最大输出张数": ("INT", {"default": 16, "min": 1, "max": 144, "step": 1}),
}
}
RETURN_TYPES = ("IMAGE", "STRING")
RETURN_NAMES = ("切割图像", "检测信息")
FUNCTION = "split_grid"
CATEGORY = "o1key/image"
DESCRIPTION = (
"智能切割 AI 生成的九宫格、六宫格等合并图。"
"自动模式会检测常见布局;没有明显分隔线时建议手动选择布局。"
)
def split_grid(
self,
图像: torch.Tensor,
布局: str = "auto",
自定义行数: int = 3,
自定义列数: int = 3,
搜索范围px: int = 32,
裁掉分隔线: bool = True,
裁掉外边距: bool = True,
最小分隔线px: int = 2,
最大输出张数: int = 16,
):
source_images = tensor_to_pil(图像)
all_crops: List[Image.Image] = []
info_lines: List[str] = []
for batch_index, image in enumerate(source_images, start=1):
crops, info = _split_one(
image=image,
layout=布局,
custom_rows=自定义行数,
custom_cols=自定义列数,
search_px=搜索范围px,
crop_separators=裁掉分隔线,
trim_outer=裁掉外边距,
min_separator_px=最小分隔线px,
)
if len(crops) > 最大输出张数:
raise ValueError(
f"合并图切割:检测到 {len(crops)} 张,超过最大输出张数 {最大输出张数}"
"请调大最大输出张数,或检查布局设置。"
)
all_crops.extend(crops)
info_lines.append(f"batch {batch_index}: {info}")
if not all_crops:
raise ValueError("合并图切割:没有生成任何切片。")
all_crops = _normalize_sizes(all_crops)
print("[o1key 合并图切割] " + " | ".join(info_lines))
return (pil_to_tensor(all_crops), "\n".join(info_lines))
+136 -57
View File
@@ -22,7 +22,7 @@ from PIL import Image
from comfy_api.latest import io
from ..utils.image_utils import tensor_to_pil, pil_to_tensor, parse_batch_prompts, encode_image_to_base64, encode_image_to_base64_limited
from ..utils.image_utils import tensor_to_pil, pil_to_tensor, parse_batch_prompts, encode_images_for_image_size_limit
from ..utils.config import (
NETWORK_ROUTE_OPTIONS,
get_base_url_by_route,
@@ -43,6 +43,14 @@ try:
except ImportError:
PROGRESS_BAR_AVAILABLE = False
try:
from comfy.model_management import processing_interrupted, InterruptProcessingException
INTERRUPT_AVAILABLE = True
except ImportError:
INTERRUPT_AVAILABLE = False
InterruptProcessingException = RuntimeError
processing_interrupted = lambda: False
try:
import psutil
MEMORY_MONITOR_AVAILABLE = True
@@ -54,6 +62,8 @@ REQUEST_LOG_ENABLED = False
_NODE = "Nano Banana"
_ENDPOINT = "/v1/chat/completions"
_REQUEST_TIMEOUT = 900
_INTERRUPT_CHECK_INTERVAL = 0.2
_client_instance = None
@@ -65,6 +75,43 @@ def _get_client():
return _client_instance
async def _poll_interrupt():
while True:
await asyncio.sleep(_INTERRUPT_CHECK_INTERVAL)
if INTERRUPT_AVAILABLE and processing_interrupted():
return
async def _run_with_interrupt(coro):
if not INTERRUPT_AVAILABLE:
return await coro
request_task = asyncio.ensure_future(coro)
interrupt_task = asyncio.ensure_future(_poll_interrupt())
done, pending = await asyncio.wait(
[request_task, interrupt_task],
return_when=asyncio.FIRST_COMPLETED,
)
for task in pending:
task.cancel()
try:
await task
except (asyncio.CancelledError, Exception):
pass
if interrupt_task in done and request_task not in done:
raise InterruptProcessingException()
return request_task.result()
def _check_interrupt():
if INTERRUPT_AVAILABLE and processing_interrupted():
raise InterruptProcessingException()
def _images_to_tensor_safe(images: List[Image.Image], node_label: str) -> torch.Tensor:
if not images:
placeholder = Image.new('RGB', (512, 512), color=(128, 128, 128))
@@ -143,22 +190,6 @@ def _build_request_body(
enable_grounding: bool = False,
thinking_level: Optional[str] = None,
) -> dict:
content_parts = [{"type": "text", "text": prompt}]
if images:
for img in images:
b64 = encode_image_to_base64_limited(img, format="PNG")
content_parts.append({
"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{b64}"}
})
body = {
"model": model,
"stream": True,
"messages": [{"role": "user", "content": content_parts}],
}
google_config = {
"image_config": {
"image_size": resolution,
@@ -171,12 +202,32 @@ def _build_request_body(
"thinking_level": thinking_level.lower(),
"include_thoughts": True,
}
body["extra_body"] = {"google": google_config}
if enable_grounding:
body["extra_body"]["google_search"] = True
def _make_body(encoded_images: Optional[List[tuple]] = None) -> dict:
content_parts = [{"type": "text", "text": prompt}]
if encoded_images:
for mime_type, b64 in encoded_images:
content_parts.append({
"type": "image_url",
"image_url": {"url": f"data:{mime_type};base64,{b64}"}
})
return body
body = {
"model": model,
"stream": True,
"messages": [{"role": "user", "content": content_parts}],
"extra_body": {"google": google_config},
}
if enable_grounding:
body["extra_body"]["google_search"] = True
return body
encoded_images = None
if images:
encoded_images = encode_images_for_image_size_limit(images)
return _make_body(encoded_images)
async def _generate_single(
@@ -208,8 +259,11 @@ async def _generate_single(
print(f"[请求] POST {url} | model={model} | extra_body={extra}")
last_status = None
resp = None
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT, connect=30, sock_read=_REQUEST_TIMEOUT)
for attempt in range(DEFAULT_MAX_RETRIES + 1):
resp = await session.post(url, headers=headers, json=body)
_check_interrupt()
resp = await session.post(url, headers=headers, json=body, timeout=timeout)
if resp.status == 200:
break
last_status = resp.status
@@ -239,27 +293,36 @@ async def _generate_single(
buffer = ""
t_request = time.time()
t_first_token = None
async for raw_chunk in resp.content.iter_any():
if t_first_token is None:
t_first_token = time.time()
buffer += raw_chunk.decode("utf-8")
while "\n" in buffer:
line_str, buffer = buffer.split("\n", 1)
line_str = line_str.strip()
if not line_str or not line_str.startswith("data:"):
continue
data_str = line_str[5:].strip()
if data_str == "[DONE]":
break
try:
chunk = json.loads(data_str)
delta = chunk.get("choices", [{}])[0].get("delta", {})
if "content" in delta:
full_content += delta["content"]
except (json.JSONDecodeError, IndexError):
continue
try:
async for raw_chunk in resp.content.iter_any():
_check_interrupt()
if t_first_token is None:
t_first_token = time.time()
buffer += raw_chunk.decode("utf-8")
while "\n" in buffer:
line_str, buffer = buffer.split("\n", 1)
line_str = line_str.strip()
if not line_str or not line_str.startswith("data:"):
continue
data_str = line_str[5:].strip()
if data_str == "[DONE]":
break
try:
chunk = json.loads(data_str)
delta = chunk.get("choices", [{}])[0].get("delta", {})
if "content" in delta:
full_content += delta["content"]
except (json.JSONDecodeError, IndexError):
continue
except aiohttp.ClientPayloadError as e:
if full_content and _IMAGE_RE.search(full_content):
print(f"Nano Banana: 响应流提前结束,但已收到完整图片,继续解析 ({e})")
else:
raise RuntimeError(f"响应流下载中断,请重试或检查网络/代理: {e}") from None
finally:
if resp is not None:
resp.close()
t_done = time.time()
resp.close()
if not full_content:
raise RuntimeError("API 未返回有效内容")
@@ -316,6 +379,8 @@ async def _generate_single_task(
result["output_images"] = gen_images
result["success"] = True
result["generated_count"] = len(gen_images)
except InterruptProcessingException:
raise
except Exception as e:
result["error"] = str(e)
return result
@@ -352,11 +417,13 @@ async def _process_batch_async(
async with aiohttp.ClientSession(connector=connector) as session:
for batch_idx in range(num_batches):
_check_interrupt()
start_idx = batch_idx * max_concurrent
end_idx = min(start_idx + max_concurrent, total_tasks)
tasks = []
for i in range(start_idx, end_idx):
_check_interrupt()
_, _, prompt = tasks_def[i]
task = asyncio.create_task(
_generate_single_task(
@@ -377,6 +444,7 @@ async def _process_batch_async(
batch_results = []
for coro in asyncio.as_completed(tasks):
_check_interrupt()
result_data = None
try:
result = await coro
@@ -384,6 +452,11 @@ async def _process_batch_async(
result_data = {"success": False, "error": str(result), "generated_count": 0, "output_images": [], "prompt": ""}
else:
result_data = result
except InterruptProcessingException:
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
raise
except Exception as e:
result_data = {"success": False, "error": str(e), "generated_count": 0, "output_images": [], "prompt": ""}
@@ -472,6 +545,7 @@ class NanoBanana(io.ComfyNode):
@classmethod
def execute(cls, prompt, 模型, 生图数量, 计费, 网络, 谷歌搜索, seed, **kwargs) -> io.NodeOutput:
start_time = time.time()
was_interrupted = False
model_name = 模型["模型"]
宽高比 = 模型["宽高比"]
@@ -533,7 +607,7 @@ class NanoBanana(io.ComfyNode):
asyncio.set_event_loop(loop)
try:
return loop.run_until_complete(
_process_batch_async(
_run_with_interrupt(_process_batch_async(
base_url=base_url,
api_key=api_key,
prompts=prompts,
@@ -545,7 +619,7 @@ class NanoBanana(io.ComfyNode):
pbar=pbar,
enable_grounding=enable_grounding,
thinking_level=thinking_level,
)
))
)
finally:
loop.close()
@@ -553,9 +627,9 @@ class NanoBanana(io.ComfyNode):
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(run_async_in_thread)
try:
results = future.result(timeout=900)
results = future.result(timeout=_REQUEST_TIMEOUT)
except TimeoutError:
raise RuntimeError("任务执行超时(900秒)")
raise RuntimeError(f"任务执行超时({_REQUEST_TIMEOUT}秒)")
success_count = sum(1 for r in results if r.get("success", False))
fail_count = len(results) - success_count
@@ -594,13 +668,13 @@ class NanoBanana(io.ComfyNode):
enable_grounding=enable_grounding,
thinking_level=thinking_level,
)
return loop.run_until_complete(_do())
return loop.run_until_complete(_run_with_interrupt(_do()))
finally:
loop.close()
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(run_single)
generated_images, first_token_ms, download_ms = future.result(timeout=900)
generated_images, first_token_ms, download_ms = future.result(timeout=_REQUEST_TIMEOUT)
if pbar is not None:
pbar.update(1)
@@ -615,6 +689,10 @@ class NanoBanana(io.ComfyNode):
import gc; gc.collect()
return io.NodeOutput(output_tensor)
except InterruptProcessingException:
was_interrupted = True
print("Nano Banana: 用户取消")
raise
except ValueError as e:
if str(e) == "未授权!":
print("请联系作者授权后方可使用!")
@@ -625,12 +703,13 @@ class NanoBanana(io.ComfyNode):
except Exception as e:
raise type(e)(str(e)) from None
finally:
try:
client = _get_client()
client.base_url = base_url
balance_data = client.query_balance_sync()
balance_info = client.format_balance_info(balance_data)
print(f"Nano Banana: {balance_info}")
except Exception:
pass
import gc; gc.collect()
if not was_interrupted:
try:
client = _get_client()
client.base_url = base_url
balance_data = client.query_balance_sync()
balance_info = client.format_balance_info(balance_data)
print(f"Nano Banana: {balance_info}")
except Exception:
pass
import gc; gc.collect()