feat: 新增首尾帧 K3 自研节点、代理参数重命名、Gemini 客户端优化
- 新增 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:小幅优化
This commit is contained in:
+3
-1
@@ -22,7 +22,7 @@ except Exception:
|
|||||||
import ssl
|
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, KVideo
|
||||||
from .nodes import K3Video
|
from .nodes import K3Video, K3VideoFirstLast
|
||||||
|
|
||||||
# 报错弹框友好文案(不修改原节点代码,仅在外层统一处理)
|
# 报错弹框友好文案(不修改原节点代码,仅在外层统一处理)
|
||||||
_MSG_TIMEOUT = "API 请求超时,请稍后重试或检查网络。"
|
_MSG_TIMEOUT = "API 请求超时,请稍后重试或检查网络。"
|
||||||
@@ -83,6 +83,7 @@ NODE_CLASS_MAPPINGS = {
|
|||||||
"O1keyGPTImage": O1keyGPTImage,
|
"O1keyGPTImage": O1keyGPTImage,
|
||||||
"KVideo": KVideo,
|
"KVideo": KVideo,
|
||||||
"K3Video": K3Video,
|
"K3Video": K3Video,
|
||||||
|
"K3VideoFirstLast": K3VideoFirstLast,
|
||||||
}
|
}
|
||||||
|
|
||||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||||
@@ -110,6 +111,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
|||||||
"O1keyGPTImage": "o1key GPT Image",
|
"O1keyGPTImage": "o1key GPT Image",
|
||||||
"KVideo": "K26 图生视频",
|
"KVideo": "K26 图生视频",
|
||||||
"K3Video": "K3 图生视频 自研",
|
"K3Video": "K3 图生视频 自研",
|
||||||
|
"K3VideoFirstLast": "首尾帧 K3 自研",
|
||||||
}
|
}
|
||||||
|
|
||||||
WEB_DIRECTORY = "./web"
|
WEB_DIRECTORY = "./web"
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ class BaseAPIClient(ABC):
|
|||||||
self.base_url = base_url
|
self.base_url = base_url
|
||||||
self.api_key = api_key
|
self.api_key = api_key
|
||||||
self.max_request_size = max_request_size
|
self.max_request_size = max_request_size
|
||||||
|
self.proxy_url: Optional[str] = None # 由节点在调用前注入,如 "http://127.0.0.1:7897"
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get_endpoint(self, **kwargs) -> str:
|
def get_endpoint(self, **kwargs) -> str:
|
||||||
@@ -200,7 +201,7 @@ class BaseAPIClient(ABC):
|
|||||||
|
|
||||||
async def _do_request():
|
async def _do_request():
|
||||||
connect_start = time.time()
|
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
|
connect_time = time.time() - connect_start
|
||||||
|
|
||||||
if response.status != 200:
|
if response.status != 200:
|
||||||
|
|||||||
+102
-7
@@ -38,6 +38,23 @@ class GeminiAPIClient(BaseAPIClient):
|
|||||||
max_request_size=100 * 1024 * 1024
|
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:
|
def get_endpoint(self, model: str = "", resolution: str = "2K", **kwargs) -> str:
|
||||||
"""
|
"""
|
||||||
根据模型和分辨率获取 API 端点
|
根据模型和分辨率获取 API 端点
|
||||||
@@ -124,6 +141,37 @@ class GeminiAPIClient(BaseAPIClient):
|
|||||||
return "服务无法在截止期限内完成处理。可能原因是:您的提示词过大,无法及时处理。"
|
return "服务无法在截止期限内完成处理。可能原因是:您的提示词过大,无法及时处理。"
|
||||||
return None
|
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(
|
def build_request_body(
|
||||||
self,
|
self,
|
||||||
prompt: str = "",
|
prompt: str = "",
|
||||||
@@ -148,6 +196,8 @@ class GeminiAPIClient(BaseAPIClient):
|
|||||||
Returns:
|
Returns:
|
||||||
请求体字典
|
请求体字典
|
||||||
"""
|
"""
|
||||||
|
_MAX_BODY_BYTES = 20 * 1024 * 1024 # 20 MB
|
||||||
|
|
||||||
parts = []
|
parts = []
|
||||||
|
|
||||||
# 添加文本部分
|
# 添加文本部分
|
||||||
@@ -155,14 +205,59 @@ class GeminiAPIClient(BaseAPIClient):
|
|||||||
|
|
||||||
# 添加图像部分(如果有)
|
# 添加图像部分(如果有)
|
||||||
if images:
|
if images:
|
||||||
for img in images:
|
working_images = list(images)
|
||||||
img_base64 = encode_image_to_base64(img)
|
|
||||||
parts.append({
|
# 编码一次,估算大小,超限则迭代缩放
|
||||||
"inline_data": {
|
for _attempt in range(10):
|
||||||
"mime_type": "image/png",
|
img_parts = []
|
||||||
"data": img_base64
|
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 = {
|
request_body = {
|
||||||
|
|||||||
@@ -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 自研",
|
||||||
|
}
|
||||||
+2
-1
@@ -22,5 +22,6 @@ from .doubao_image import DoubaoImage
|
|||||||
from .gpt_image import O1keyGPTImage
|
from .gpt_image import O1keyGPTImage
|
||||||
from .K_video import KVideo
|
from .K_video import KVideo
|
||||||
from .K3_video import K3Video
|
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']
|
||||||
|
|||||||
@@ -215,6 +215,12 @@ class BatchNanoBananaPro:
|
|||||||
"default": "不配对"
|
"default": "不配对"
|
||||||
})
|
})
|
||||||
|
|
||||||
|
optional_inputs["代理加速"] = ("STRING", {
|
||||||
|
"default": "",
|
||||||
|
"multiline": False,
|
||||||
|
"placeholder": "本地代理端口,如 7897(Clash Verge)或 10808(v2rayN),留空不使用"
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"required": {
|
"required": {
|
||||||
"prompt": ("STRING", {
|
"prompt": ("STRING", {
|
||||||
@@ -742,6 +748,7 @@ class BatchNanoBananaPro:
|
|||||||
# 从 kwargs 提取搜索参数(界面显示为「关闭/打开」,转为 bool 供调用)
|
# 从 kwargs 提取搜索参数(界面显示为「关闭/打开」,转为 bool 供调用)
|
||||||
enable_grounding: bool = (kwargs.pop("谷歌搜索(联网)", "关闭") == "打开")
|
enable_grounding: bool = (kwargs.pop("谷歌搜索(联网)", "关闭") == "打开")
|
||||||
enable_image_search: bool = (kwargs.pop("图片搜索(联网)", "关闭") == "打开")
|
enable_image_search: bool = (kwargs.pop("图片搜索(联网)", "关闭") == "打开")
|
||||||
|
proxy_port: str = kwargs.pop("代理加速", "")
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -882,6 +889,11 @@ class BatchNanoBananaPro:
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise ValueError(f"初始化 API 客户端失败: {str(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 目录
|
# 判断是否使用默认 output 目录
|
||||||
original_save_path = kwargs.get('保存路径', '')
|
original_save_path = kwargs.get('保存路径', '')
|
||||||
user_set_save_path = bool(original_save_path and original_save_path.strip())
|
user_set_save_path = bool(original_save_path and original_save_path.strip())
|
||||||
|
|||||||
@@ -150,6 +150,12 @@ class NanoBananaPro:
|
|||||||
for i in range(1, 10): # 1-9
|
for i in range(1, 10): # 1-9
|
||||||
optional_inputs[f"参考图{i}"] = ("IMAGE",)
|
optional_inputs[f"参考图{i}"] = ("IMAGE",)
|
||||||
|
|
||||||
|
optional_inputs["代理端口(如7897)"] = ("STRING", {
|
||||||
|
"default": "",
|
||||||
|
"multiline": False,
|
||||||
|
"placeholder": "本地代理端口,如 7897(Clash Verge)或 10808(v2rayN),留空不使用"
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"required": {
|
"required": {
|
||||||
"prompt": ("STRING", {
|
"prompt": ("STRING", {
|
||||||
@@ -464,6 +470,7 @@ class NanoBananaPro:
|
|||||||
# 从 kwargs 提取搜索参数(界面显示为「关闭/打开」,转为 bool 供调用)
|
# 从 kwargs 提取搜索参数(界面显示为「关闭/打开」,转为 bool 供调用)
|
||||||
enable_grounding: bool = (kwargs.pop("谷歌搜索(联网)", "关闭") == "打开")
|
enable_grounding: bool = (kwargs.pop("谷歌搜索(联网)", "关闭") == "打开")
|
||||||
enable_image_search: bool = (kwargs.pop("图片搜索(联网)", "关闭") == "打开")
|
enable_image_search: bool = (kwargs.pop("图片搜索(联网)", "关闭") == "打开")
|
||||||
|
proxy_port: str = kwargs.pop("代理端口(如7897)", "")
|
||||||
|
|
||||||
# 创建 ComfyUI 原生进度条
|
# 创建 ComfyUI 原生进度条
|
||||||
pbar = None
|
pbar = None
|
||||||
@@ -489,6 +496,11 @@ class NanoBananaPro:
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise ValueError(f"初始化失败: {str(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(模型)
|
supported_resolutions = get_model_supported_resolutions(模型)
|
||||||
if supported_resolutions and 分辨率 not in supported_resolutions:
|
if supported_resolutions and 分辨率 not in supported_resolutions:
|
||||||
|
|||||||
Reference in New Issue
Block a user