- 新增 Grok Image 节点及客户端 - 新增 save_image_format 节点 - 新增前端 JS 扩展:画笔工具、点阵网格、侧边栏隐藏、资源切换、重命名等 - 重构 nano-banana 节点,移除 pro 版本 - 移除 multi_res_preview 节点 - 新增 http_error 工具模块 - 各客户端和节点优化改进 Co-Authored-By: Claude Opus 4.6 <[email protected]>
238 lines
8.9 KiB
Python
238 lines
8.9 KiB
Python
"""
|
||
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_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
|
||
|
||
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": "关闭"}),
|
||
"网络线路": (NETWORK_ROUTE_OPTIONS, {"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_base_url_by_route(网络线路)
|
||
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}"
|
||
resp = await async_request_with_retry(
|
||
session, "POST", create_url, json=body, headers=headers, prefix="K26 图生视频提交: "
|
||
)
|
||
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 图生视频",
|
||
}
|