Update image and video workflow nodes
This commit is contained in:
+27
-22
@@ -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
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 = [
|
||||
"智能",
|
||||
"1024x1024(1K 正方形 1:1)",
|
||||
"1536x1024(1K 横版 3:2)",
|
||||
"1024x1536(1K 竖版 2:3)",
|
||||
"1360x1024(1K 横版 4:3)",
|
||||
"1024x1360(1K 竖版 3:4)",
|
||||
"1824x1024(1K 横版 16:9)",
|
||||
"1024x1824(1K 竖版 9:16)",
|
||||
"2048x2048(2K 正方形 1:1)",
|
||||
"3072x2048(2K 横版 3:2)",
|
||||
"2048x3072(2K 竖版 2:3)",
|
||||
"2736x2048(2K 横版 4:3)",
|
||||
"2048x2736(2K 竖版 3:4)",
|
||||
"3648x2048(2K 横版 16:9)",
|
||||
"2048x3648(2K 竖版 9:16)",
|
||||
"2880x2880(4K 正方形 1:1)",
|
||||
"3504x2336(4K 横版 3:2)",
|
||||
"2336x3504(4K 竖版 2:3)",
|
||||
"3264x2448(4K 横版 4:3)",
|
||||
"2448x3264(4K 竖版 3:4)",
|
||||
"3840x2160(4K 横版 16:9)",
|
||||
"2160x3840(4K 竖版 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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user