feat: 接入动作控制节点并全面重构 Kling 节点

- 动作控制节点改走 new API 三段式流程(POST /v1/videos → 轮询 → 下载)
- KlingClient 新增 motion_control_async 方法,支持 id 轮询和内容下载
- 动作控制请求体字段更新:image→image_url,video→video_url,补充 model 字段
- 动作控制 model 名按平台规范动态拼接:kling-{版本}-motion-{mode}-{时长}s
- 动作控制节点新增时长参数(5/10/15s),完善参数顺序与命名
- 三个 Kling 节点显示名称统一重命名
- KlingVideo / KlingFirstLastFrame 补充 v2-6 模型支持与约束校验

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
This commit is contained in:
Jony
2026-04-05 01:14:54 +08:00
co-authored by Claude Sonnet 4.5
parent 2b64a45b8e
commit 9abd175316
3 changed files with 186 additions and 41 deletions
+3 -3
View File
@@ -93,9 +93,9 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"GoogleVeo": "Google Veo - ab", "GoogleVeo": "Google Veo - ab",
"FluxImageEdit": "Flux2 图像编辑", "FluxImageEdit": "Flux2 图像编辑",
"UniversalLLMChat": "全能LLM对话助手", "UniversalLLMChat": "全能LLM对话助手",
"KlingVideo": "自研模型 3.0 视频", "KlingVideo": "文/图生视频 自研模型",
"KlingFirstLastFrame": "自研模型 3.0 首尾帧到视频", "KlingFirstLastFrame": "首尾帧生视频 自研模型",
"KlingMotionControlTest": "自研模型 动作控制(测试)", "KlingMotionControlTest": "动作控制 自研模型",
"QuanNengShengTu": "全能生图", "QuanNengShengTu": "全能生图",
"BatchQuanNengShengTu": "全能生图(批量)", "BatchQuanNengShengTu": "全能生图(批量)",
"AspectRatioPreset": "图片宽高比预设", "AspectRatioPreset": "图片宽高比预设",
+116
View File
@@ -21,6 +21,11 @@ class KlingClient:
"motion_control": "/kling/v1/videos/motion-control", "motion_control": "/kling/v1/videos/motion-control",
} }
# new API 三段式端点(动作控制走这里)
NEW_API_CREATE = "/v1/videos"
NEW_API_STATUS = "/v1/videos/{video_id}"
NEW_API_CONTENT = "/v1/videos/{video_id}/content"
POLL_INITIAL_INTERVAL = 3 POLL_INITIAL_INTERVAL = 3
POLL_MAX_INTERVAL = 15 POLL_MAX_INTERVAL = 15
@@ -172,3 +177,114 @@ class KlingClient:
if on_stage: if on_stage:
on_stage("done") on_stage("done")
return path return path
# ── 动作控制:走 new API 三段式流程 ──────────────────────────────
async def motion_control_async(
self,
body: Dict[str, Any],
save_path: str,
on_stage: Optional[Callable[[str], None]] = None,
on_progress: Optional[Callable[[int], None]] = None,
) -> str:
"""
动作控制专用入口:
POST /v1/videos → GET /v1/videos/{id} → GET /v1/videos/{id}/content
body 字段与 Kling 官方动作控制接口一致(image_url/video_url/prompt/...)。
"""
headers = {"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json"}
interval = self.POLL_INITIAL_INTERVAL
connector = aiohttp.TCPConnector(force_close=True)
async with aiohttp.ClientSession(connector=connector) as session:
# 1. 提交
if on_stage:
on_stage("submitting")
create_url = f"{self.base_url}{self.NEW_API_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"动作控制提交失败 ({resp.status}): {msg}")
create_resp = json.loads(text)
video_id = create_resp.get("id")
if not video_id:
raise RuntimeError(f"API 未返回视频 ID,响应:{create_resp}")
if on_stage:
on_stage(f"submitted:{video_id}")
# 2. 轮询
status_url = f"{self.base_url}{self.NEW_API_STATUS.format(video_id=video_id)}"
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}")
status_resp = json.loads(text)
status = status_resp.get("status", "").lower()
progress_raw = status_resp.get("progress", 0)
try:
progress_pct = int(str(progress_raw).rstrip("%").strip())
except (ValueError, AttributeError):
progress_pct = 0
print(f"[动作控制] 生成中 {progress_pct}%")
if on_progress:
on_progress(progress_pct)
if status == "completed":
break
if status == "failed":
error_info = status_resp.get("error", {})
error_msg = (error_info.get("message", "未知错误")
if isinstance(error_info, dict) else str(error_info))
raise RuntimeError(f"动作控制生成失败:{error_msg}")
await asyncio.sleep(interval)
interval = min(interval * 1.5, self.POLL_MAX_INTERVAL)
# 3. 下载
if on_stage:
on_stage("downloading")
content_url = f"{self.base_url}{self.NEW_API_CONTENT.format(video_id=video_id)}"
async with session.get(content_url, headers=headers,
allow_redirects=True) as resp:
if resp.status != 200:
raise RuntimeError(f"视频下载失败 ({resp.status})")
content_type = resp.headers.get("Content-Type", "")
if "application/json" in content_type:
data = await resp.json()
download_url = data.get("url") or data.get("download_url")
if not download_url:
raise RuntimeError("视频下载失败:响应中未找到下载链接")
async with session.get(download_url) as dl_resp:
if dl_resp.status != 200:
raise RuntimeError(f"从下载链接获取视频失败 ({dl_resp.status})")
os.makedirs(os.path.dirname(save_path), exist_ok=True)
with open(save_path, "wb") as f:
async for chunk in dl_resp.content.iter_chunked(8192):
f.write(chunk)
else:
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)
if on_stage:
on_stage("done")
return save_path
+67 -38
View File
@@ -142,7 +142,7 @@ def _validate_image(tensor, label: str = "图片") -> None:
class KlingVideo: class KlingVideo:
"""Kling 3.0 视频生成节点(支持多镜头)""" """Kling 视频生成节点(支持多镜头)"""
@classmethod @classmethod
def INPUT_TYPES(cls): def INPUT_TYPES(cls):
@@ -150,6 +150,7 @@ class KlingVideo:
"required": { "required": {
"提示词": ("STRING", {"multiline": True, "default": ""}), "提示词": ("STRING", {"multiline": True, "default": ""}),
"反向提示词": ("STRING", {"multiline": True, "default": ""}), "反向提示词": ("STRING", {"multiline": True, "default": ""}),
"模型版本": (["v3", "v2-6"], {"default": "v3"}),
"时长": ([5, 10, 15],), "时长": ([5, 10, 15],),
"分辨率": (["1080p", "720p"],), "分辨率": (["1080p", "720p"],),
"宽高比": (["智能", "16:9", "9:16", "1:1"], {"default": "智能"}), "宽高比": (["智能", "16:9", "9:16", "1:1"], {"default": "智能"}),
@@ -159,17 +160,17 @@ class KlingVideo:
"optional": { "optional": {
"起始帧": ("IMAGE",), "起始帧": ("IMAGE",),
"镜头1_提示词": ("STRING", {"multiline": True, "default": ""}), "镜头1_提示词": ("STRING", {"multiline": True, "default": ""}),
"镜头1_时长": ("INT", {"default": 5, "min": 1, "max": 15, "step": 1}), "镜头1_时长": ("STRING", {"default": "5"}),
"镜头2_提示词": ("STRING", {"multiline": True, "default": ""}), "镜头2_提示词": ("STRING", {"multiline": True, "default": ""}),
"镜头2_时长": ("INT", {"default": 5, "min": 1, "max": 15, "step": 1}), "镜头2_时长": ("STRING", {"default": "5"}),
"镜头3_提示词": ("STRING", {"multiline": True, "default": ""}), "镜头3_提示词": ("STRING", {"multiline": True, "default": ""}),
"镜头3_时长": ("INT", {"default": 5, "min": 1, "max": 15, "step": 1}), "镜头3_时长": ("STRING", {"default": "5"}),
"镜头4_提示词": ("STRING", {"multiline": True, "default": ""}), "镜头4_提示词": ("STRING", {"multiline": True, "default": ""}),
"镜头4_时长": ("INT", {"default": 5, "min": 1, "max": 15, "step": 1}), "镜头4_时长": ("STRING", {"default": "5"}),
"镜头5_提示词": ("STRING", {"multiline": True, "default": ""}), "镜头5_提示词": ("STRING", {"multiline": True, "default": ""}),
"镜头5_时长": ("INT", {"default": 5, "min": 1, "max": 15, "step": 1}), "镜头5_时长": ("STRING", {"default": "5"}),
"镜头6_提示词": ("STRING", {"multiline": True, "default": ""}), "镜头6_提示词": ("STRING", {"multiline": True, "default": ""}),
"镜头6_时长": ("INT", {"default": 5, "min": 1, "max": 15, "step": 1}), "镜头6_时长": ("STRING", {"default": "5"}),
} }
} }
@@ -182,6 +183,7 @@ class KlingVideo:
"""生成视频(支持多镜头)""" """生成视频(支持多镜头)"""
prompt = kwargs["提示词"] prompt = kwargs["提示词"]
negative_prompt = kwargs["反向提示词"] negative_prompt = kwargs["反向提示词"]
model_ver = kwargs.get("模型版本", "v3")
duration = kwargs["时长"] duration = kwargs["时长"]
resolution = kwargs["分辨率"] resolution = kwargs["分辨率"]
aspect_ratio = kwargs["宽高比"] aspect_ratio = kwargs["宽高比"]
@@ -192,12 +194,27 @@ class KlingVideo:
mode = "pro" if resolution == "1080p" else "std" mode = "pro" if resolution == "1080p" else "std"
voice = "voice" if generate_audio == "打开" else "novoice" voice = "voice" if generate_audio == "打开" else "novoice"
# ── v2-6 模型约束校验 ──────────────────────────────────────────
if model_ver == "v2-6":
if duration == 15:
raise ValueError(
"v2-6 模型不支持 15s 时长,请选择 5s 或 10s。"
)
if mode == "std" and voice == "voice":
raise ValueError(
"v2-6 模型的标准画质(720p)不支持生成音频,请关闭生成音频或切换至 1080p。"
)
# ── 多镜头检测 ──────────────────────────────────────────────── # ── 多镜头检测 ────────────────────────────────────────────────
multi_prompt_list = [] multi_prompt_list = []
for i in range(1, 7): for i in range(1, 7):
sb_prompt = kwargs.get(f"镜头{i}_提示词", "").strip() sb_prompt = kwargs.get(f"镜头{i}_提示词", "").strip()
if sb_prompt: if sb_prompt:
sb_duration = kwargs.get(f"镜头{i}_时长", 5) raw_dur = kwargs.get(f"镜头{i}_时长", "5")
try:
sb_duration = int(str(raw_dur).strip()) if str(raw_dur).strip() else 5
except ValueError:
sb_duration = 5
multi_prompt_list.append({ multi_prompt_list.append({
"index": i, "index": i,
"prompt": sb_prompt, "prompt": sb_prompt,
@@ -219,7 +236,7 @@ class KlingVideo:
# ── 构建模型名 & 请求体 ─────────────────────────────────────── # ── 构建模型名 & 请求体 ───────────────────────────────────────
import json, base64, copy import json, base64, copy
model_name = f"kling-v3-{mode}-{duration}s-{voice}" model_name = f"kling-{model_ver}-{mode}-{duration}s-{voice}"
body = { body = {
"model": model_name, "model": model_name,
@@ -312,7 +329,7 @@ class KlingVideo:
class KlingFirstLastFrame: class KlingFirstLastFrame:
"""Kling 3.0 首尾帧到视频节点""" """Kling 首尾帧到视频节点"""
@classmethod @classmethod
def INPUT_TYPES(cls): def INPUT_TYPES(cls):
@@ -321,10 +338,10 @@ class KlingFirstLastFrame:
"首帧": ("IMAGE",), "首帧": ("IMAGE",),
"尾帧": ("IMAGE",), "尾帧": ("IMAGE",),
"提示词": ("STRING", {"multiline": True, "default": ""}), "提示词": ("STRING", {"multiline": True, "default": ""}),
"模型": (["v3", "v2-6"], {"default": "v3"}),
"分辨率": (["1080p", "720p"],),
"时长": ([5, 10, 15],), "时长": ([5, 10, 15],),
"生成音频": (["打开", "关闭"], {"default": "打开"}), "生成音频": (["打开", "关闭"], {"default": "打开"}),
"模型": (["v3"],),
"分辨率": (["1080p", "720p"],),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffff}), "seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffff}),
} }
} }
@@ -341,7 +358,7 @@ class KlingFirstLastFrame:
duration = kwargs["时长"] duration = kwargs["时长"]
generate_audio = kwargs["生成音频"] generate_audio = kwargs["生成音频"]
model_base = kwargs["模型"] model_base = kwargs["模型"]
model_base = "kling-" + model_base # v3 → kling-v3(后端值还原) model_base = "kling-" + model_base # v3/v2-6 → kling-v3/kling-v2-6(后端值还原)
resolution = kwargs["分辨率"] resolution = kwargs["分辨率"]
seed = kwargs.get("seed", 0) # noqa: F841 — 触发 ComfyUI 缓存刷新 seed = kwargs.get("seed", 0) # noqa: F841 — 触发 ComfyUI 缓存刷新
@@ -351,9 +368,22 @@ class KlingFirstLastFrame:
if duration not in (5, 10, 15): if duration not in (5, 10, 15):
raise ValueError(f"时长仅支持 5、10、15 秒,当前值为 {duration},请重新选择。") raise ValueError(f"时长仅支持 5、10、15 秒,当前值为 {duration},请重新选择。")
# 拼接模型名:kling-v3-{mode}-{dur}s-{voice} # 拼接模型名:kling-{ver}-{mode}-{dur}s-{voice}
mode = "pro" if resolution == "1080p" else "std" mode = "pro" if resolution == "1080p" else "std"
voice = "voice" if generate_audio == "打开" else "novoice" voice = "voice" if generate_audio == "打开" else "novoice"
# ── v2-6 模型约束校验 ──────────────────────────────────────────
model_ver = kwargs["模型"] # "v3" or "v2-6"
if model_ver == "v2-6":
if duration == 15:
raise ValueError(
"v2-6 模型不支持 15s 时长,请选择 5s 或 10s。"
)
if mode == "std" and voice == "voice":
raise ValueError(
"v2-6 模型的标准画质(720p)不支持生成音频,请关闭生成音频或切换至 1080p。"
)
model_name = f"{model_base}-{mode}-{duration}s-{voice}" model_name = f"{model_base}-{mode}-{duration}s-{voice}"
# 图片校验 & 转 base64 # 图片校验 & 转 base64
@@ -459,10 +489,11 @@ class KlingMotionControlTest:
"参考视频": ("VIDEO",), "参考视频": ("VIDEO",),
}, },
"optional": { "optional": {
"保留原声": ("BOOLEAN", {"default": True}), "模型": (["v3", "v2-6"], {"default": "v3"}),
"分辨率": (["1080p", "720p"],),
"时长": ([5, 10, 15], {"default": 5}),
"人物朝向": (["video", "image"],), "人物朝向": (["video", "image"],),
"画质模式": (["专家", "标准"],), "保留原声": (["打开", "关闭"], {"default": "打开"}),
"模型版本": (["v3"],),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffff}), "seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffff}),
}, },
} }
@@ -473,18 +504,19 @@ class KlingMotionControlTest:
CATEGORY = "comfyui_o1key/Kling" CATEGORY = "comfyui_o1key/Kling"
async def generate(self, **kwargs): async def generate(self, **kwargs):
"""动作控制(测试):VIDEO 类型参考视频 + 图片人物动作迁移""" """动作控制:VIDEO 类型参考视频 + 图片人物动作迁移(走 new API 三段式)"""
import base64 import base64
prompt = kwargs["提示词"] prompt = kwargs["提示词"]
reference_image = kwargs["参考图片"] reference_image = kwargs["参考图片"]
reference_video = kwargs["参考视频"] reference_video = kwargs["参考视频"]
keep_original_sound = kwargs.get("保留原声", True) keep_original_sound = kwargs.get("保留原声", "打开")
character_orientation = kwargs.get("人物朝向", "video") character_orientation = kwargs.get("人物朝向", "video")
mode = kwargs.get("画质模式", "专家") mode = kwargs.get("分辨率", "1080p")
mode = "pro" if mode == "专家" else "std" # 映射为 API 参数值 duration = kwargs.get("时长", 5)
model = kwargs.get("模型版本", "v3") mode_api = "pro" if mode == "1080p" else "std" # 映射为 API 参数值
model = "kling-" + model # v3 → kling-v3(后端值还原) model = kwargs.get("模型", "v3")
model_name = f"kling-{model}-motion-{mode_api}-{duration}s"
seed = kwargs.get("seed", 0) # noqa: F841 — 触发 ComfyUI 缓存刷新 seed = kwargs.get("seed", 0) # noqa: F841 — 触发 ComfyUI 缓存刷新
# ── 校验提示词 ──────────────────────────────────────────────── # ── 校验提示词 ────────────────────────────────────────────────
@@ -495,7 +527,6 @@ class KlingMotionControlTest:
image_b64 = _tensor_to_base64(reference_image) image_b64 = _tensor_to_base64(reference_image)
# ── 从 VIDEO 对象获取本地文件路径并读取 ─────────────────────── # ── 从 VIDEO 对象获取本地文件路径并读取 ───────────────────────
# ComfyUI VIDEO 对象有 .source_path 或通过 VideoFromFile 构造
video_path = None video_path = None
if hasattr(reference_video, "source_path"): if hasattr(reference_video, "source_path"):
video_path = reference_video.source_path video_path = reference_video.source_path
@@ -537,7 +568,6 @@ class KlingMotionControlTest:
f"参考视频时长须在 3~10 秒之间,当前为 {duration_sec:.1f}s。" f"参考视频时长须在 3~10 秒之间,当前为 {duration_sec:.1f}s。"
) )
except FileNotFoundError: except FileNotFoundError:
# ffprobe 不可用时跳过时长校验,但打印提示
print("[动作控制] 警告:ffprobe 未找到,跳过视频时长校验。") print("[动作控制] 警告:ffprobe 未找到,跳过视频时长校验。")
except ValueError: except ValueError:
raise raise
@@ -548,21 +578,21 @@ class KlingMotionControlTest:
with open(video_path, "rb") as f: with open(video_path, "rb") as f:
video_b64 = base64.b64encode(f.read()).decode("utf-8") video_b64 = base64.b64encode(f.read()).decode("utf-8")
# ── 构建请求体 ──────────────────────────────────────────────── # ── 构建请求体(new API 格式)─────────────────────────────────
body = { body = {
"model": model_name,
"prompt": prompt, "prompt": prompt,
"image_url": image_b64,
"video_url": video_b64,
"character_orientation": character_orientation, "character_orientation": character_orientation,
"mode": mode, "mode": mode_api,
"model": model, "keep_original_sound": "yes" if keep_original_sound == "打开" else "no",
"keep_original_sound": "yes" if keep_original_sound else "no",
"image": image_b64,
"video": video_b64,
} }
# ── 保存路径 ────────────────────────────────────────────────── # ── 保存路径 ──────────────────────────────────────────────────
video_dir = _get_video_output_dir() video_dir = _get_video_output_dir()
counter = _get_next_counter(video_dir, "kling_motion_test") counter = _get_next_counter(video_dir, "kling_motion")
save_path = os.path.join(video_dir, f"kling_motion_test_{counter:05d}.mp4") save_path = os.path.join(video_dir, f"kling_motion_{counter:05d}.mp4")
client = KlingClient() client = KlingClient()
@@ -592,8 +622,7 @@ class KlingMotionControlTest:
if pbar: pbar.update_absolute(mapped, 100) if pbar: pbar.update_absolute(mapped, 100)
try: try:
result_path = await client.generate_async( result_path = await client.motion_control_async(
endpoint_type="motion_control",
body=body, body=body,
save_path=save_path, save_path=save_path,
on_stage=on_stage, on_stage=on_stage,
@@ -729,8 +758,8 @@ NODE_CLASS_MAPPINGS = {
} }
NODE_DISPLAY_NAME_MAPPINGS = { NODE_DISPLAY_NAME_MAPPINGS = {
"KlingVideo": "自研模型 3.0 视频", "KlingVideo": "文/图生视频 自研模型",
"KlingFirstLastFrame": "自研模型 3.0 首尾帧到视频", "KlingFirstLastFrame": "首尾帧生视频 自研模型",
"KlingMotionControlTest": "自研模型 动作控制(测试)", "KlingMotionControlTest": "动作控制 自研模型",
"AspectRatioPreset": "图片宽高比预设", "AspectRatioPreset": "图片宽高比预设",
} }