From 1a813bfd1de32cf9dc35d4924e73cd2e36410729 Mon Sep 17 00:00:00 2001 From: o1key <951565127@qq.com> Date: Thu, 7 May 2026 09:48:24 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=9B=BE=E7=94=9F=E8=A7=86=E9=A2=91?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E8=BF=9B=E5=BA=A6=E8=A7=A3=E6=9E=90=E5=85=BC?= =?UTF-8?q?=E5=AE=B9=E7=99=BE=E5=88=86=E5=8F=B7=E6=A0=BC=E5=BC=8F=EF=BC=8C?= =?UTF-8?q?K26/K3/Seedance=E7=BB=9F=E4=B8=80=E8=B5=B0cf-api=E5=BC=82?= =?UTF-8?q?=E6=AD=A5=E6=8E=A5=E5=8F=A3=EF=BC=8CK26=E6=96=B0=E5=A2=9Eseed?= =?UTF-8?q?=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复 K_video_image2video.py 进度值 "10%" 解析报错:rstrip('%') 后 float→int 安全转换 - K26 节点拆分为图生视频/首尾帧两个独立节点,删除旧的合并节点 K_video.py - K26/K3 视频节点及 Seedance 客户端统一改用 get_async_api_base_url (cf-api.o1key.com) - K3 节点模式选项从 标准/专家 改为 720p/1080p,与后端一致 - K_video_image2video.py 和 K_video_firstlast.py 新增 ComfyUI 原生 seed 参数 Co-Authored-By: Claude Opus 4.6 --- __init__.py | 8 +- clients/seedance_client.py | 4 +- nodes/K3_motion_control.py | 8 +- nodes/K3_video.py | 10 +- nodes/K3_video_firstlast.py | 10 +- nodes/{K_video.py => K_video_firstlast.py} | 84 ++++++-- nodes/K_video_image2video.py | 236 +++++++++++++++++++++ nodes/__init__.py | 5 +- nodes/nano_banana_v2.py | 11 +- utils/r2_uploader.py | 6 +- 10 files changed, 333 insertions(+), 49 deletions(-) rename nodes/{K_video.py => K_video_firstlast.py} (71%) create mode 100644 nodes/K_video_image2video.py diff --git a/__init__.py b/__init__.py index 2b4dae7..4997e73 100644 --- a/__init__.py +++ b/__init__.py @@ -12,7 +12,7 @@ Comfyui_o1key - ComfyUI 自定义节点集合 import ssl -from .nodes import NanoBananaPro, BatchNanoBananaPro, GoogleGemini, LoadFile, ImageStitchPro, SaveCleanImage, BatchCleanMetadata, VideoPreview, GoogleVeo, FluxImageEdit, UniversalLLMChat, KlingVideo, KlingFirstLastFrame, KlingMotionControlTest, AspectRatioPreset, MultiResPreview, BatchImagesO1key, Seedance, SeedanceMultiModal, StreamPreview, DoubaoImage, O1keyGPTImage, KVideo +from .nodes import NanoBananaPro, BatchNanoBananaPro, GoogleGemini, LoadFile, ImageStitchPro, SaveCleanImage, BatchCleanMetadata, VideoPreview, GoogleVeo, FluxImageEdit, UniversalLLMChat, KlingVideo, KlingFirstLastFrame, KlingMotionControlTest, AspectRatioPreset, MultiResPreview, BatchImagesO1key, Seedance, SeedanceMultiModal, StreamPreview, DoubaoImage, O1keyGPTImage, KVideoFirstLast, KVideoImage2Video from .nodes import K3Video, K3VideoFirstLast, K3MotionControl, K3MotionVideoCheck, NanoBananaV2, NanoBananaV2Batch # 报错弹框友好文案(不修改原节点代码,仅在外层统一处理) @@ -74,7 +74,8 @@ NODE_CLASS_MAPPINGS = { "StreamPreview": StreamPreview, "DoubaoImage": DoubaoImage, "O1keyGPTImage": O1keyGPTImage, - "KVideo": KVideo, + "KVideoFirstLast": KVideoFirstLast, + "KVideoImage2Video": KVideoImage2Video, "K3Video": K3Video, "K3VideoFirstLast": K3VideoFirstLast, "K3MotionControl": K3MotionControl, @@ -106,7 +107,8 @@ NODE_DISPLAY_NAME_MAPPINGS = { "StreamPreview": "流式文本预览", "DoubaoImage": "豆包生图", "O1keyGPTImage": "o1key GPT Image", - "KVideo": "K26 图生视频", + "KVideoFirstLast": "K26 图生视频(首尾帧)", + "KVideoImage2Video": "K26 图生视频", "K3Video": "K3 图生视频 自研", "K3VideoFirstLast": "首尾帧 K3 自研", "K3MotionControl": "动作控制 K3 自研", diff --git a/clients/seedance_client.py b/clients/seedance_client.py index 5dd20ef..350cdea 100644 --- a/clients/seedance_client.py +++ b/clients/seedance_client.py @@ -10,7 +10,7 @@ from typing import Any, Callable, Dict, Optional import aiohttp -from ..utils.config import get_api_key_or_raise, get_api_base_url +from ..utils.config import get_api_key_or_raise, get_async_api_base_url class SeedanceClient: @@ -30,7 +30,7 @@ class SeedanceClient: def __init__(self): self.api_key = get_api_key_or_raise() - self.base_url = get_api_base_url() + self.base_url = get_async_api_base_url() def _headers(self) -> Dict[str, str]: return { diff --git a/nodes/K3_motion_control.py b/nodes/K3_motion_control.py index 1379d32..14ea3f4 100644 --- a/nodes/K3_motion_control.py +++ b/nodes/K3_motion_control.py @@ -13,7 +13,7 @@ import tempfile import aiohttp -from ..utils.config import get_api_key_or_raise, get_api_base_url +from ..utils.config import get_api_key_or_raise, get_async_api_base_url from ..utils.r2_uploader import upload_video, upload_image from ..utils.image_utils import tensor_to_pil @@ -107,7 +107,7 @@ class K3MotionControl: "参考视频": ("VIDEO",), "提示词": ("STRING", {"multiline": True, "default": ""}), "模型": (["v3", "v2-6"], {"default": "v3"}), - "模式": (["标准", "专家"], {"default": "专家"}), + "模式": (["720p", "1080p"], {"default": "1080p"}), "时长": ([5, 10, 15, 20, 25, 30], {"default": 5}), "角色朝向": (["图片", "视频"], {"default": "图片"}), "保留原声": (["打开", "关闭"], {"default": "打开"}), @@ -125,14 +125,14 @@ class K3MotionControl: async def generate(self, 参考图片, 参考视频, 提示词, 保留原声, 角色朝向, 模式, 模型, 时长, seed, **kwargs): api_key = get_api_key_or_raise() - base_url = get_api_base_url() + base_url = get_async_api_base_url() headers = { "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", } # ── 参数映射 ────────────────────────────────────────────────── - mode_api = "std" if 模式 == "标准" else "pro" + mode_api = "std" if 模式 == "720p" else "pro" model_name = f"kling-{模型}-motion-{mode_api}-{时长}s" character_orientation = "image" if 角色朝向 == "图片" else "video" keep_sound = "yes" if 保留原声 == "打开" else "no" diff --git a/nodes/K3_video.py b/nodes/K3_video.py index c7ce14f..72a2615 100644 --- a/nodes/K3_video.py +++ b/nodes/K3_video.py @@ -11,7 +11,7 @@ import tempfile import aiohttp -from ..utils.config import get_api_key_or_raise, get_api_base_url +from ..utils.config import get_api_key_or_raise, get_async_api_base_url from ..utils.image_utils import tensor_to_pil, encode_image_to_base64 try: @@ -25,8 +25,8 @@ except Exception: # ── 常量 ────────────────────────────────────────────────────────────────────── _MODEL_BASE = "kling-v3" # 动态拼接为 kling-v3-{模式}-{时长}s-{voice} -_MODES = ["标准", "专家", "4K"] -_MODE_MAP = {"标准": "std", "专家": "pro", "4K": "4k"} +_MODES = ["720p", "1080p", "4K"] +_MODE_MAP = {"720p": "std", "1080p": "pro", "4K": "4k"} _MULTI_SHOT_OPTIONS = [ "禁用", @@ -112,7 +112,7 @@ class K3Video: "负向提示词": ("STRING", {"multiline": True, "default": ""}), "时长": ([5, 10, 15], {"default": 5}), "生成音频": (["关闭", "打开"], {"default": "关闭"}), - "模式": (_MODES, {"default": "标准"}), + "模式": (_MODES, {"default": "720p"}), "seed": ("INT", { "default": 0, "min": 0, "max": 2147483647, "tooltip": "seed 仅控制节点是否重新运行,结果本身不可复现。", @@ -139,7 +139,7 @@ class K3Video: async def generate(self, 多镜头, 起始帧, 提示词, 负向提示词, 时长, 生成音频, 模式, seed, **kwargs): api_key = get_api_key_or_raise() - base_url = get_api_base_url() + base_url = get_async_api_base_url() headers = { "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", diff --git a/nodes/K3_video_firstlast.py b/nodes/K3_video_firstlast.py index d19f492..be14307 100644 --- a/nodes/K3_video_firstlast.py +++ b/nodes/K3_video_firstlast.py @@ -10,7 +10,7 @@ import tempfile import aiohttp -from ..utils.config import get_api_key_or_raise, get_api_base_url +from ..utils.config import get_api_key_or_raise, get_async_api_base_url from ..utils.image_utils import tensor_to_pil, encode_image_to_base64 try: @@ -24,8 +24,8 @@ except Exception: # ── 常量 ────────────────────────────────────────────────────────────────────── _MODEL_BASE = "kling-v3" -_MODES = ["标准", "专家", "4K"] -_MODE_MAP = {"标准": "std", "专家": "pro", "4K": "4k"} +_MODES = ["720p", "1080p", "4K"] +_MODE_MAP = {"720p": "std", "1080p": "pro", "4K": "4k"} _ENDPOINT_CREATE = "/v1/video/generations" _ENDPOINT_STATUS = "/v1/video/generations/{task_id}" @@ -93,7 +93,7 @@ class K3VideoFirstLast: "负向提示词": ("STRING", {"multiline": True, "default": ""}), "时长": ([5, 10, 15], {"default": 5}), "生成音频": (["关闭", "打开"], {"default": "关闭"}), - "模式": (_MODES, {"default": "标准"}), + "模式": (_MODES, {"default": "720p"}), "seed": ("INT", { "default": 0, "min": 0, "max": 2147483647, "tooltip": "seed 仅控制节点是否重新运行,结果本身不可复现。", @@ -111,7 +111,7 @@ class K3VideoFirstLast: async def generate(self, 起始帧, 提示词, 负向提示词, 时长, 生成音频, 模式, seed, 尾帧=None): api_key = get_api_key_or_raise() - base_url = get_api_base_url() + base_url = get_async_api_base_url() headers = { "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", diff --git a/nodes/K_video.py b/nodes/K_video_firstlast.py similarity index 71% rename from nodes/K_video.py rename to nodes/K_video_firstlast.py index 2a25ed2..b93dffa 100644 --- a/nodes/K_video.py +++ b/nodes/K_video_firstlast.py @@ -4,12 +4,13 @@ K26 图生视频节点 import asyncio import json +import math import os import tempfile import aiohttp -from ..utils.config import get_api_key_or_raise, get_api_base_url +from ..utils.config import get_api_key_or_raise, get_async_api_base_url from ..utils.image_utils import tensor_to_pil, encode_image_to_base64 try: @@ -30,13 +31,20 @@ _POLL_INIT = 3 _POLL_MAX = 15 -def _image_to_base64(tensor) -> str: +def _image_to_base64(tensor, scale=1.0) -> str: + from PIL import Image pil = tensor_to_pil(tensor) - return encode_image_to_base64(pil[0], format="PNG") + img = pil[0] + if scale < 1.0: + w, h = img.size + new_w = max(1, int(w * scale)) + new_h = max(1, int(h * scale)) + img = img.resize((new_w, new_h), Image.LANCZOS) + return encode_image_to_base64(img, format="PNG") -class KVideo: - """K26 图生视频节点""" +class KVideoFirstLast: + """K26 图生视频节点(首尾帧)""" @classmethod def INPUT_TYPES(cls): @@ -44,9 +52,13 @@ class KVideo: "required": { "起始帧": ("IMAGE",), "提示词": ("STRING", {"multiline": True, "default": ""}), - "模式": (["pro"],), + "模式": (["1080p"],), "时长": ([5, 10],), "生成音频": (["关闭", "打开"], {"default": "关闭"}), + "seed": ("INT", { + "default": 0, "min": 0, "max": 2147483647, + "tooltip": "seed 仅控制节点是否重新运行,结果本身不可复现。", + }), }, "optional": { "尾帧": ("IMAGE",), @@ -58,30 +70,56 @@ class KVideo: FUNCTION = "generate" CATEGORY = "comfyui_o1key/KVideo" - async def generate(self, 起始帧, 提示词, 模式, 时长, 生成音频="关闭", 尾帧=None): + async def generate(self, 起始帧, 提示词, 模式, 时长, 生成音频="关闭", 尾帧=None, seed=0): api_key = get_api_key_or_raise() - base_url = get_api_base_url() + base_url = get_async_api_base_url() headers = { "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", } # ── 动态拼接模型名 ──────────────────────────────────────────── + mode_api = "pro" # 1080p 映射为 pro voice = "voice" if 生成音频 == "打开" else "novoice" - model_name = f"{_MODEL_BASE}-{模式}-{时长}s-{voice}" + model_name = f"{_MODEL_BASE}-{mode_api}-{时长}s-{voice}" - # ── 构建请求体 ──────────────────────────────────────────────── - body = { - "model": model_name, - "prompt": 提示词.strip(), - "image": _image_to_base64(起始帧), - "mode": 模式, - "duration": 时长, - } - if 生成音频 == "打开": - body["generate_audio"] = True - if 尾帧 is not None: - body["metadata"] = {"image_tail": _image_to_base64(尾帧)} + # ── 构建请求体(超过 10MB 自动缩放图片)──────────────────────── + MAX_BODY = 10 * 1024 * 1024 + scale = 1.0 + + print(f"[K26 图生视频] 请求体大小限制: 10MB,超出将自动缩放图片") + + while True: + body = { + "model": model_name, + "prompt": 提示词.strip(), + "image": _image_to_base64(起始帧, scale), + "mode": mode_api, + "duration": 时长, + } + if 生成音频 == "打开": + body["generate_audio"] = True + if 尾帧 is not None: + body["metadata"] = {"image_tail": _image_to_base64(尾帧, scale)} + + body_str = json.dumps(body, ensure_ascii=False) + body_size = len(body_str.encode("utf-8")) + + if body_size <= MAX_BODY: + print(f"[K26 图生视频] 请求体大小: {body_size / 1024 / 1024:.2f}MB" + + (f"(已缩放至 {scale:.1%})" if scale < 1.0 else "")) + break + + # 等比缩放:图片像素面积与 base64 长度近似线性 + target_ratio = MAX_BODY / body_size + scale = scale * math.sqrt(target_ratio) * 0.95 # 5% 安全余量 + + if scale < 0.01: + raise RuntimeError("图片缩放后仍超过10MB限制,请使用更小的参考图") + + w, h = tensor_to_pil(起始帧)[0].size + print(f"[K26 图生视频] 请求体 {body_size / 1024 / 1024:.2f}MB 超限," + f"自动缩放至 {scale:.1%}({int(w * scale)}x{int(h * scale)})") # ── 进度条 ──────────────────────────────────────────────────── try: @@ -205,9 +243,9 @@ class KVideo: NODE_CLASS_MAPPINGS = { - "KVideo": KVideo, + "KVideoFirstLast": KVideoFirstLast, } NODE_DISPLAY_NAME_MAPPINGS = { - "KVideo": "K26 图生视频", + "KVideoFirstLast": "K26 图生视频(首尾帧)", } diff --git a/nodes/K_video_image2video.py b/nodes/K_video_image2video.py new file mode 100644 index 0000000..c959bff --- /dev/null +++ b/nodes/K_video_image2video.py @@ -0,0 +1,236 @@ +""" +K26 图生视频节点 +支持 720p 和 1080p 模式 +""" + +import asyncio +import json +import math +import os +import tempfile + +import aiohttp + +from ..utils.config import get_api_key_or_raise, get_async_api_base_url +from ..utils.image_utils import tensor_to_pil, encode_image_to_base64 + +try: + from comfy_api.latest import InputImpl + import folder_paths + _FOLDER_PATHS_OK = True +except ImportError: + _FOLDER_PATHS_OK = False + +# 模型基础名,运行时动态拼接完整名称 +_MODEL_BASE = "kling-v2-6" + +# API 端点 +_ENDPOINT_CREATE = "/v1/video/generations" +_ENDPOINT_STATUS = "/v1/video/generations/{task_id}" + +_POLL_INIT = 3 +_POLL_MAX = 15 + + +def _image_to_base64(tensor, scale=1.0) -> str: + from PIL import Image + pil = tensor_to_pil(tensor) + img = pil[0] + if scale < 1.0: + w, h = img.size + new_w = max(1, int(w * scale)) + new_h = max(1, int(h * scale)) + img = img.resize((new_w, new_h), Image.LANCZOS) + return encode_image_to_base64(img, format="PNG") + + +class KVideoImage2Video: + """K26 图生视频节点""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "起始帧": ("IMAGE",), + "提示词": ("STRING", {"multiline": True, "default": ""}), + "模式": (["720p", "1080p"], {"default": "720p"}), + "时长": ([5, 10], {"default": 5}), + "生成音频": (["关闭", "打开"], {"default": "关闭"}), + "seed": ("INT", { + "default": 0, "min": 0, "max": 2147483647, + "tooltip": "seed 仅控制节点是否重新运行,结果本身不可复现。", + }), + }, + } + + RETURN_TYPES = ("VIDEO",) + RETURN_NAMES = ("视频",) + FUNCTION = "generate" + CATEGORY = "comfyui_o1key/KVideo" + + async def generate(self, 起始帧, 提示词, 模式, 时长, 生成音频="关闭", seed=0): + api_key = get_api_key_or_raise() + base_url = get_async_api_base_url() + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + + # ── 动态拼接模型名 ──────────────────────────────────────────── + mode_api = "std" if 模式 == "720p" else "pro" + voice = "voice" if 生成音频 == "打开" else "novoice" + model_name = f"{_MODEL_BASE}-{mode_api}-{时长}s-{voice}" + + # ── 构建请求体(超过 10MB 自动缩放图片)──────────────────────── + MAX_BODY = 10 * 1024 * 1024 + scale = 1.0 + + print(f"[K26 图生视频] 请求体大小限制: 10MB,超出将自动缩放图片") + + while True: + body = { + "model": model_name, + "prompt": 提示词.strip(), + "image": _image_to_base64(起始帧, scale), + "mode": mode_api, + "duration": 时长, + } + if 生成音频 == "打开": + body["generate_audio"] = True + + body_str = json.dumps(body, ensure_ascii=False) + body_size = len(body_str.encode("utf-8")) + + if body_size <= MAX_BODY: + print(f"[K26 图生视频] 请求体大小: {body_size / 1024 / 1024:.2f}MB" + + (f"(已缩放至 {scale:.1%})" if scale < 1.0 else "")) + break + + # 等比缩放:图片像素面积与 base64 长度近似线性 + target_ratio = MAX_BODY / body_size + scale = scale * math.sqrt(target_ratio) * 0.95 # 5% 安全余量 + + if scale < 0.01: + raise RuntimeError("图片缩放后仍超过10MB限制,请使用更小的参考图") + + w, h = tensor_to_pil(起始帧)[0].size + print(f"[K26 图生视频] 请求体 {body_size / 1024 / 1024:.2f}MB 超限," + f"自动缩放至 {scale:.1%}({int(w * scale)}x{int(h * scale)})") + + # ── 进度条 ──────────────────────────────────────────────────── + try: + from comfy.utils import ProgressBar + pbar = ProgressBar(100) + except Exception: + pbar = None + + def _stage(s: str): + if s == "submitting": + print("[K26 图生视频] 提交中...") + if pbar: pbar.update_absolute(0, 100) + elif s.startswith("submitted:"): + print(f"[K26 图生视频] 任务已提交 → {s.split(':', 1)[1]}") + if pbar: pbar.update_absolute(5, 100) + elif s == "downloading": + print("[K26 图生视频] 下载视频...") + if pbar: pbar.update_absolute(99, 100) + elif s == "done": + print("[K26 图生视频] 完成") + if pbar: pbar.update_absolute(100, 100) + + def _progress(pct: int): + if pbar: pbar.update_absolute(5 + int(pct * 0.94), 100) + + # ── 保存路径(临时文件,避免与下游保存节点重复落盘)────────────────── + tmp_fd, save_path = tempfile.mkstemp(suffix=".mp4", prefix="k26_") + + connector = aiohttp.TCPConnector(ssl=False, force_close=True) + async with aiohttp.ClientSession(connector=connector) as session: + + # 1. 提交 + _stage("submitting") + create_url = f"{base_url}{_ENDPOINT_CREATE}" + async with session.post(create_url, json=body, headers=headers) as resp: + if resp.status != 200: + err_text = await resp.text() + raise RuntimeError(f"提交失败 ({resp.status}): {err_text}") + sr = await resp.json() + + task_id = sr.get("task_id") or sr.get("id") + if not task_id: + raise RuntimeError(f"API 未返回 task_id,响应:{sr}") + + _stage(f"submitted:{task_id}") + + # 2. 轮询 + status_url = f"{base_url}{_ENDPOINT_STATUS.format(task_id=task_id)}" + interval = _POLL_INIT + video_url = None + + while True: + await asyncio.sleep(interval) + + async with session.get(status_url, headers=headers) as resp: + if resp.status != 200: + err_text = await resp.text() + raise RuntimeError(f"查询失败 ({resp.status}): {err_text}") + sr = await resp.json() + + data = sr.get("data", {}) or {} + status = (sr.get("status") or data.get("status") or "").lower() + + pct_raw = str(data.get("progress", 0)).strip().rstrip('%') + try: + pct = max(0, min(100, int(float(pct_raw)))) + except (ValueError, TypeError): + pct = 0 + print(f"[K26 图生视频] 生成中 {pct}%") + _progress(pct) + + if status in ("success", "completed", "done", "finished", "succeed"): + # 提取视频 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") + ) + 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)) + 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. 下载 + _stage("downloading") + async with session.get(video_url, allow_redirects=True) as resp: + if resp.status != 200: + raise RuntimeError(f"视频下载失败 ({resp.status})") + os.close(tmp_fd) + with open(save_path, "wb") as f: + async for chunk in resp.content.iter_chunked(8192): + f.write(chunk) + + _stage("done") + + if _FOLDER_PATHS_OK: + return (InputImpl.VideoFromFile(save_path),) + return (save_path,) + + +NODE_CLASS_MAPPINGS = { + "KVideoImage2Video": KVideoImage2Video, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "KVideoImage2Video": "K26 图生视频", +} diff --git a/nodes/__init__.py b/nodes/__init__.py index 23d53fa..2ff6d87 100644 --- a/nodes/__init__.py +++ b/nodes/__init__.py @@ -21,9 +21,10 @@ 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 .K_video import KVideo +from .K_video_firstlast import KVideoFirstLast +from .K_video_image2video import KVideoImage2Video from .K3_video import K3Video from .K3_video_firstlast import K3VideoFirstLast from .K3_motion_control import K3MotionControl, K3MotionVideoCheck -__all__ = ['NanoBananaV2', 'NanoBananaV2Batch', 'NanoBananaPro', 'BatchNanoBananaPro', 'GoogleGemini', 'LoadFile', 'ImageStitchPro', 'SaveCleanImage', 'BatchCleanMetadata', 'VideoPreview', 'KlingVideo', 'KlingFirstLastFrame', 'KlingMotionControlTest', 'AspectRatioPreset', 'GoogleVeo', 'FluxImageEdit', 'UniversalLLMChat', 'MultiResPreview', 'BatchImagesO1key', 'Seedance', 'SeedanceMultiModal', 'StreamPreview', 'DoubaoImage', 'O1keyGPTImage', 'KVideo', 'K3Video', 'K3VideoFirstLast', 'K3MotionControl', 'K3MotionVideoCheck', 'AsyncImageGenerator', 'BatchAsyncImageGenerator'] +__all__ = ['NanoBananaV2', 'NanoBananaV2Batch', 'NanoBananaPro', 'BatchNanoBananaPro', 'GoogleGemini', 'LoadFile', 'ImageStitchPro', 'SaveCleanImage', 'BatchCleanMetadata', 'VideoPreview', 'KlingVideo', 'KlingFirstLastFrame', 'KlingMotionControlTest', 'AspectRatioPreset', 'GoogleVeo', 'FluxImageEdit', 'UniversalLLMChat', 'MultiResPreview', 'BatchImagesO1key', 'Seedance', 'SeedanceMultiModal', 'StreamPreview', 'DoubaoImage', 'O1keyGPTImage', 'KVideoFirstLast', 'KVideoImage2Video', 'K3Video', 'K3VideoFirstLast', 'K3MotionControl', 'K3MotionVideoCheck', 'AsyncImageGenerator', 'BatchAsyncImageGenerator'] diff --git a/nodes/nano_banana_v2.py b/nodes/nano_banana_v2.py index b21b670..29b8f55 100644 --- a/nodes/nano_banana_v2.py +++ b/nodes/nano_banana_v2.py @@ -62,8 +62,9 @@ except ImportError: DEBUG_LOG_ENABLED = False REQUEST_LOG_ENABLED = False -_POLL_INTERVAL = 2 # 轮询间隔(秒) -_MAX_WAIT_TIME = 900 # 单任务最大等待时间(秒) +_POLL_INTERVAL = 2 # 轮询间隔(秒) +_INTERRUPT_CHECK_INTERVAL = 0.1 # 取消检查间隔(秒) +_MAX_WAIT_TIME = 900 # 单任务最大等待时间(秒) def _images_to_tensor_safe(images: List[Image.Image], node_label: str) -> torch.Tensor: @@ -374,7 +375,11 @@ class NanoBananaV2: friendly_msg = self._friendly_error(error_msg) raise RuntimeError(f"任务失败: {friendly_msg}") elif status in ("SUBMITTED", "IN_PROGRESS"): - await asyncio.sleep(_POLL_INTERVAL) + # 分段 sleep,每 0.1 秒检查一次取消信号 + sleep_iterations = int(_POLL_INTERVAL / _INTERRUPT_CHECK_INTERVAL) + for _ in range(sleep_iterations): + self._check_interrupt() + await asyncio.sleep(_INTERRUPT_CHECK_INTERVAL) else: raise RuntimeError(f"未知任务状态: {status}") diff --git a/utils/r2_uploader.py b/utils/r2_uploader.py index fd0a1c6..b4e2982 100644 --- a/utils/r2_uploader.py +++ b/utils/r2_uploader.py @@ -18,7 +18,8 @@ async def _presign(filename: str, content_type: str) -> tuple: api_key = get_api_key_or_raise() base_url = get_api_base_url() - async with aiohttp.ClientSession() as session: + connector = aiohttp.TCPConnector(ssl=False) + async with aiohttp.ClientSession(connector=connector) as session: async with session.post( f"{base_url}/v1/storage/presign", headers={"Authorization": f"Bearer {api_key}"}, @@ -35,7 +36,8 @@ async def _presign(filename: str, content_type: str) -> tuple: async def _put_upload(upload_url: str, data: bytes, content_type: str): """用预签名 URL 直传文件到 R2(不带 Authorization)""" - async with aiohttp.ClientSession() as session: + connector = aiohttp.TCPConnector(ssl=False) + async with aiohttp.ClientSession(connector=connector) as session: async with session.put( upload_url, data=data,