From 1941357ae48e1444958709c705b2bf79db0a223e Mon Sep 17 00:00:00 2001 From: Jony <951565127@qq.com> Date: Sun, 26 Apr 2026 00:47:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E9=A6=96=E5=B0=BE?= =?UTF-8?q?=E5=B8=A7=20K3=20=E8=87=AA=E7=A0=94=E8=8A=82=E7=82=B9=E3=80=81?= =?UTF-8?q?=E4=BB=A3=E7=90=86=E5=8F=82=E6=95=B0=E9=87=8D=E5=91=BD=E5=90=8D?= =?UTF-8?q?=E3=80=81Gemini=20=E5=AE=A2=E6=88=B7=E7=AB=AF=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 K3VideoFirstLast 节点(首尾帧 K3 自研):基于 K3Video 去掉分镜,新增可选尾帧输入,尾帧通过 metadata.image_tail 传递 - nano_banana_pro / batch_nano_banana_pro:将参数「代理加速」重命名为「代理端口(如7897)」 - gemini_client:新增 build_proxy_url、_estimate_body_size、_scale_images_to_fit 工具方法 - base_client:小幅优化 --- __init__.py | 4 +- clients/base_client.py | 3 +- clients/gemini_client.py | 109 +++++++++++- nodes/K3_video_firstlast.py | 307 +++++++++++++++++++++++++++++++++ nodes/__init__.py | 3 +- nodes/batch_nano_banana_pro.py | 12 ++ nodes/nano_banana_pro.py | 12 ++ 7 files changed, 440 insertions(+), 10 deletions(-) create mode 100644 nodes/K3_video_firstlast.py diff --git a/__init__.py b/__init__.py index a638f99..568de88 100644 --- a/__init__.py +++ b/__init__.py @@ -22,7 +22,7 @@ except Exception: 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 K3Video +from .nodes import K3Video, K3VideoFirstLast # 报错弹框友好文案(不修改原节点代码,仅在外层统一处理) _MSG_TIMEOUT = "API 请求超时,请稍后重试或检查网络。" @@ -83,6 +83,7 @@ NODE_CLASS_MAPPINGS = { "O1keyGPTImage": O1keyGPTImage, "KVideo": KVideo, "K3Video": K3Video, + "K3VideoFirstLast": K3VideoFirstLast, } NODE_DISPLAY_NAME_MAPPINGS = { @@ -110,6 +111,7 @@ NODE_DISPLAY_NAME_MAPPINGS = { "O1keyGPTImage": "o1key GPT Image", "KVideo": "K26 图生视频", "K3Video": "K3 图生视频 自研", + "K3VideoFirstLast": "首尾帧 K3 自研", } WEB_DIRECTORY = "./web" diff --git a/clients/base_client.py b/clients/base_client.py index 4cea467..ffd4fc8 100644 --- a/clients/base_client.py +++ b/clients/base_client.py @@ -43,6 +43,7 @@ class BaseAPIClient(ABC): self.base_url = base_url self.api_key = api_key self.max_request_size = max_request_size + self.proxy_url: Optional[str] = None # 由节点在调用前注入,如 "http://127.0.0.1:7897" @abstractmethod def get_endpoint(self, **kwargs) -> str: @@ -200,7 +201,7 @@ class BaseAPIClient(ABC): async def _do_request(): connect_start = time.time() - async with session.post(url, json=request_body, headers=headers, timeout=_aiohttp_timeout) as response: + async with session.post(url, json=request_body, headers=headers, timeout=_aiohttp_timeout, proxy=self.proxy_url) as response: connect_time = time.time() - connect_start if response.status != 200: diff --git a/clients/gemini_client.py b/clients/gemini_client.py index 3b085d9..570095f 100644 --- a/clients/gemini_client.py +++ b/clients/gemini_client.py @@ -38,6 +38,23 @@ class GeminiAPIClient(BaseAPIClient): max_request_size=100 * 1024 * 1024 ) + @staticmethod + def build_proxy_url(port: str) -> Optional[str]: + """ + 将端口号字符串转为 aiohttp 可用的 HTTP 代理 URL。 + 兼容 Windows / Mac,支持 v2rayN (10808) 和 Clash Verge (7897)。 + + Args: + port: 用户填写的端口号,如 "7897" 或 "10808";空字符串返回 None + + Returns: + 代理 URL,如 "http://127.0.0.1:7897",或 None(不使用代理) + """ + port = (port or "").strip() + if not port or not port.isdigit(): + return None + return f"http://127.0.0.1:{port}" + def get_endpoint(self, model: str = "", resolution: str = "2K", **kwargs) -> str: """ 根据模型和分辨率获取 API 端点 @@ -124,6 +141,37 @@ class GeminiAPIClient(BaseAPIClient): return "服务无法在截止期限内完成处理。可能原因是:您的提示词过大,无法及时处理。" return None + @staticmethod + def _estimate_body_size(parts: list, extra_body: dict) -> int: + """ + 快速估算请求体 JSON 序列化后的字节数。 + extra_body 为除 contents[0].parts 以外的其余字段。 + """ + import json + body = { + "contents": [{"role": "user", "parts": parts}], + **extra_body + } + return len(json.dumps(body).encode("utf-8")) + + @staticmethod + def _scale_images_to_fit( + images: List[Image.Image], + scale: float + ) -> List[Image.Image]: + """ + 将所有图片按统一比例等比缩放(Lanczos,不降质量)。 + scale < 1.0 时缩小,>= 1.0 时原样返回。 + """ + if scale >= 1.0: + return images + result = [] + for img in images: + new_w = max(1, int(img.width * scale)) + new_h = max(1, int(img.height * scale)) + result.append(img.resize((new_w, new_h), Image.Resampling.LANCZOS)) + return result + def build_request_body( self, prompt: str = "", @@ -148,6 +196,8 @@ class GeminiAPIClient(BaseAPIClient): Returns: 请求体字典 """ + _MAX_BODY_BYTES = 20 * 1024 * 1024 # 20 MB + parts = [] # 添加文本部分 @@ -155,14 +205,59 @@ class GeminiAPIClient(BaseAPIClient): # 添加图像部分(如果有) if images: - for img in images: - img_base64 = encode_image_to_base64(img) - parts.append({ - "inline_data": { - "mime_type": "image/png", - "data": img_base64 + working_images = list(images) + + # 编码一次,估算大小,超限则迭代缩放 + for _attempt in range(10): + img_parts = [] + for img in working_images: + img_base64 = encode_image_to_base64(img) + img_parts.append({ + "inline_data": { + "mime_type": "image/png", + "data": img_base64 + } + }) + + # 估算完整 body 大小(不含工具字段,工具字段很小可忽略) + estimated = self._estimate_body_size( + parts + img_parts, + { + "generationConfig": { + "responseModalities": ["TEXT", "IMAGE"], + "imageConfig": { + "aspectRatio": aspect_ratio, + "imageSize": resolution + } + } } - }) + ) + + if estimated <= _MAX_BODY_BYTES: + parts.extend(img_parts) + if _attempt > 0: + orig_sizes = ", ".join( + f"{img.width}×{img.height}" for img in images + ) + new_sizes = ", ".join( + f"{img.width}×{img.height}" for img in working_images + ) + size_mb = estimated / (1024 * 1024) + print( + f"Nano Banana Pro: 输入图片已自动缩放以控制请求体积\n" + f" 原始尺寸: {orig_sizes}\n" + f" 缩放后: {new_sizes}\n" + f" 请求体积: {size_mb:.2f}MB(限制 20MB)" + ) + break + else: + # 按像素面积比推算需要的线性缩放系数,留 5% 余量 + ratio = (_MAX_BODY_BYTES * 0.95) / estimated + scale = ratio ** 0.5 # 面积比 → 线性比 + working_images = self._scale_images_to_fit(working_images, scale) + else: + # 10 轮后仍超限,使用最后一次结果(极端情况兜底) + parts.extend(img_parts) # 构建请求体 request_body = { diff --git a/nodes/K3_video_firstlast.py b/nodes/K3_video_firstlast.py new file mode 100644 index 0000000..87695a9 --- /dev/null +++ b/nodes/K3_video_firstlast.py @@ -0,0 +1,307 @@ +""" +首尾帧 K3 自研节点 +基于 K3 图生视频 自研,去掉分镜功能,新增尾帧可选输入。 +""" + +import asyncio +import json +import os +import re + +import aiohttp + +from ..utils.config import get_api_key_or_raise, get_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 Exception: + _FOLDER_PATHS_OK = False + + +# ── 常量 ────────────────────────────────────────────────────────────────────── + +_MODEL_BASE = "kling-v3" +_MODES = ["标准", "专家", "4K"] +_MODE_MAP = {"标准": "std", "专家": "pro", "4K": "4k"} + +_ENDPOINT_CREATE = "/v1/video/generations" +_ENDPOINT_STATUS = "/v1/video/generations/{task_id}" + +_POLL_INIT = 3 +_POLL_MAX = 15 + + +# ── 工具函数 ─────────────────────────────────────────────────────────────────── + +def _get_video_dir() -> str: + if _FOLDER_PATHS_OK: + base = folder_paths.get_output_directory() + else: + plugin = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + base = os.path.join(os.path.dirname(os.path.dirname(plugin)), "output") + d = os.path.join(base, "video") + os.makedirs(d, exist_ok=True) + return d + + +def _next_counter(directory: str, prefix: str) -> int: + pattern = re.compile(rf"^{re.escape(prefix)}_(\d+)") + max_n = 0 + if os.path.exists(directory): + for f in os.listdir(directory): + m = pattern.match(f) + if m: + max_n = max(max_n, int(m.group(1))) + return max_n + 1 + + +def _prepare_image_base64(tensor) -> str: + """转换并校验图片,不符合约束时自动等比缩放后返回 base64。""" + import io + import base64 + + pil_list = tensor_to_pil(tensor) + img = pil_list[0].convert("RGB") + w, h = img.size + + # 1. 宽高比校验 + ratio = w / h + if ratio < 1 / 2.5 or ratio > 2.5: + raise RuntimeError( + f"图片宽高比 {w}:{h}({ratio:.2f})超出允许范围 1:2.5 ~ 2.5:1,请裁剪后重试。" + ) + + # 2. 最小尺寸:任意边 < 300px 时等比放大 + if w < 300 or h < 300: + scale = max(300 / w, 300 / h) + img = img.resize((int(w * scale), int(h * scale)), resample=1) + + # 3. 文件大小:循环等比缩小直到 ≤ 10MB + MAX_BYTES = 10 * 1024 * 1024 + for _ in range(20): + buf = io.BytesIO() + img.save(buf, format="PNG") + if buf.tell() <= MAX_BYTES: + break + scale = (MAX_BYTES / buf.tell()) ** 0.5 * 0.95 + new_w = int(img.width * scale) + new_h = int(img.height * scale) + if new_w < 300 or new_h < 300: + raise RuntimeError( + f"图片压缩至 10MB 以内后尺寸({new_w}x{new_h})低于最小限制 300px,无法同时满足两项约束。" + ) + img = img.resize((new_w, new_h), resample=1) + else: + raise RuntimeError("图片经过 20 次缩放仍超过 10MB,请检查原始图片。") + + buf.seek(0) + return base64.b64encode(buf.read()).decode("utf-8") + + +# ── 节点 ────────────────────────────────────────────────────────────────────── + +class K3VideoFirstLast: + """首尾帧 K3 自研""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "起始帧": ("IMAGE",), + "提示词": ("STRING", {"multiline": True, "default": ""}), + "负向提示词": ("STRING", {"multiline": True, "default": ""}), + "时长": ([5, 10, 15], {"default": 5}), + "生成音频": (["关闭", "打开"], {"default": "关闭"}), + "模式": (_MODES, {"default": "标准"}), + "seed": ("INT", { + "default": 0, "min": 0, "max": 2147483647, + "tooltip": "seed 仅控制节点是否重新运行,结果本身不可复现。", + }), + }, + "optional": { + "尾帧": ("IMAGE", {"tooltip": "可选。传入后将作为视频尾帧参考。"}), + }, + } + + RETURN_TYPES = ("VIDEO",) + RETURN_NAMES = ("视频",) + FUNCTION = "generate" + CATEGORY = "comfyui_o1key/KVideo" + + async def generate(self, 起始帧, 提示词, 负向提示词, 时长, 生成音频, 模式, seed, 尾帧=None): + api_key = get_api_key_or_raise() + base_url = get_api_base_url() + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + + voice = "voice" if 生成音频 == "打开" else "novoice" + mode_api = _MODE_MAP[模式] + if mode_api == "4k": + model_name = f"{_MODEL_BASE}-4k-{时长}s" + else: + model_name = f"{_MODEL_BASE}-{mode_api}-{时长}s-{voice}" + + if not 提示词.strip(): + raise RuntimeError("提示词不能为空。") + + # ── 构建请求体 ──────────────────────────────────────────────── + body: dict = { + "model": model_name, + "prompt": 提示词.strip(), + "mode": mode_api, + "duration": 时长, + "image": _prepare_image_base64(起始帧), + } + + if 负向提示词.strip(): + body["negative_prompt"] = 负向提示词.strip() + + # metadata:尾帧 + 音频 + metadata: dict = {} + if 尾帧 is not None: + metadata["image_tail"] = _prepare_image_base64(尾帧) + if 生成音频 == "打开": + metadata["sound"] = "on" + if metadata: + body["metadata"] = metadata + + # generate_audio 字段(非 metadata 路径) + if 生成音频 == "打开" and not metadata.get("sound"): + body["generate_audio"] = True + + # ── 进度条 ──────────────────────────────────────────────────── + try: + from comfy.utils import ProgressBar + pbar = ProgressBar(100) + except Exception: + pbar = None + + def _stage(s: str): + if s == "submitting": + print("[K3 首尾帧] 提交中...") + if pbar: pbar.update_absolute(0, 100) + elif s.startswith("submitted:"): + print(f"[K3 首尾帧] 任务已提交 → {s.split(':', 1)[1]}") + if pbar: pbar.update_absolute(5, 100) + elif s == "downloading": + print("[K3 首尾帧] 下载视频...") + if pbar: pbar.update_absolute(99, 100) + elif s == "done": + print("[K3 首尾帧] 完成") + if pbar: pbar.update_absolute(100, 100) + + def _progress(pct: int): + if pbar: pbar.update_absolute(5 + int(pct * 0.94), 100) + + # ── 保存路径 ────────────────────────────────────────────────── + video_dir = _get_video_dir() + counter = _next_counter(video_dir, "k3fl") + save_path = os.path.join(video_dir, f"k3fl_{counter:05d}.mp4") + + 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: + text = await resp.text() + if resp.status != 200: + try: + err = json.loads(text) + msg = err.get("error", {}).get("message") or err.get("message") or text + except Exception: + msg = text + raise RuntimeError(f"K3 首尾帧提交失败 ({resp.status}): {msg}") + create_resp = json.loads(text) + + task_id = ( + create_resp.get("task_id") + or create_resp.get("id") + or create_resp.get("data", {}).get("task_id") + ) + if not task_id: + raise RuntimeError(f"API 未返回任务 ID,响应:{create_resp}") + _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: + async with session.get(status_url, headers=headers) as resp: + text = await resp.text() + if resp.status != 200: + try: + err = json.loads(text) + msg = err.get("error", {}).get("message") or err.get("message") or text + except Exception: + msg = text + raise RuntimeError(f"状态查询失败 ({resp.status}): {msg}") + sr = json.loads(text) + + data = sr.get("data", sr) + status = (data.get("status") or sr.get("status") or "").lower() + + pct_raw = data.get("progress", 0) + try: + pct = int(str(pct_raw).rstrip("%").strip()) + except (ValueError, AttributeError): + pct = 0 + 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") + ) + 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"K3 首尾帧生成失败:{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.makedirs(os.path.dirname(save_path), exist_ok=True) + 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 = { + "K3VideoFirstLast": K3VideoFirstLast, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "K3VideoFirstLast": "首尾帧 K3 自研", +} diff --git a/nodes/__init__.py b/nodes/__init__.py index 9e33ed8..01bdde4 100644 --- a/nodes/__init__.py +++ b/nodes/__init__.py @@ -22,5 +22,6 @@ from .doubao_image import DoubaoImage from .gpt_image import O1keyGPTImage from .K_video import KVideo from .K3_video import K3Video +from .K3_video_firstlast import K3VideoFirstLast -__all__ = ['NanoBananaPro', 'BatchNanoBananaPro', 'GoogleGemini', 'LoadFile', 'ImageStitchPro', 'SaveCleanImage', 'BatchCleanMetadata', 'VideoPreview', 'KlingVideo', 'KlingFirstLastFrame', 'KlingMotionControlTest', 'AspectRatioPreset', 'GoogleVeo', 'FluxImageEdit', 'UniversalLLMChat', 'MultiResPreview', 'BatchImagesO1key', 'Seedance', 'SeedanceMultiModal', 'StreamPreview', 'DoubaoImage', 'O1keyGPTImage', 'KVideo', 'K3Video'] +__all__ = ['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'] diff --git a/nodes/batch_nano_banana_pro.py b/nodes/batch_nano_banana_pro.py index cade6da..141d25b 100644 --- a/nodes/batch_nano_banana_pro.py +++ b/nodes/batch_nano_banana_pro.py @@ -214,6 +214,12 @@ class BatchNanoBananaPro: optional_inputs["图片配对模式"] = (cls.PAIRING_MODES, { "default": "不配对" }) + + optional_inputs["代理加速"] = ("STRING", { + "default": "", + "multiline": False, + "placeholder": "本地代理端口,如 7897(Clash Verge)或 10808(v2rayN),留空不使用" + }) return { "required": { @@ -742,6 +748,7 @@ class BatchNanoBananaPro: # 从 kwargs 提取搜索参数(界面显示为「关闭/打开」,转为 bool 供调用) enable_grounding: bool = (kwargs.pop("谷歌搜索(联网)", "关闭") == "打开") enable_image_search: bool = (kwargs.pop("图片搜索(联网)", "关闭") == "打开") + proxy_port: str = kwargs.pop("代理加速", "") try: @@ -881,6 +888,11 @@ class BatchNanoBananaPro: self.client = GeminiAPIClient() except ValueError as e: raise ValueError(f"初始化 API 客户端失败: {str(e)}") + + # 注入代理设置(每次执行都刷新,支持用户中途修改端口) + self.client.proxy_url = GeminiAPIClient.build_proxy_url(proxy_port) + if self.client.proxy_url: + print(f"BatchNanoBananaPro: 已启用代理加速 → {self.client.proxy_url}") # 判断是否使用默认 output 目录 original_save_path = kwargs.get('保存路径', '') diff --git a/nodes/nano_banana_pro.py b/nodes/nano_banana_pro.py index a6b3196..c45c122 100644 --- a/nodes/nano_banana_pro.py +++ b/nodes/nano_banana_pro.py @@ -149,6 +149,12 @@ class NanoBananaPro: optional_inputs = {} for i in range(1, 10): # 1-9 optional_inputs[f"参考图{i}"] = ("IMAGE",) + + optional_inputs["代理端口(如7897)"] = ("STRING", { + "default": "", + "multiline": False, + "placeholder": "本地代理端口,如 7897(Clash Verge)或 10808(v2rayN),留空不使用" + }) return { "required": { @@ -464,6 +470,7 @@ class NanoBananaPro: # 从 kwargs 提取搜索参数(界面显示为「关闭/打开」,转为 bool 供调用) enable_grounding: bool = (kwargs.pop("谷歌搜索(联网)", "关闭") == "打开") enable_image_search: bool = (kwargs.pop("图片搜索(联网)", "关闭") == "打开") + proxy_port: str = kwargs.pop("代理端口(如7897)", "") # 创建 ComfyUI 原生进度条 pbar = None @@ -488,6 +495,11 @@ class NanoBananaPro: self.client = GeminiAPIClient() except ValueError as e: raise ValueError(f"初始化失败: {str(e)}") + + # 注入代理设置(每次执行都刷新,支持用户中途修改端口) + self.client.proxy_url = GeminiAPIClient.build_proxy_url(proxy_port) + if self.client.proxy_url: + print(f"Nano Banana Pro: 已启用代理加速 → {self.client.proxy_url}") # 校验分辨率与模型的兼容性 supported_resolutions = get_model_supported_resolutions(模型)