Publish current ComfyUI O1Key code baseline

Replace the prior release tree with the current plugin, frontend, tests, and documentation. Document retired node IDs and the public Gitea update source.
This commit is contained in:
Jony
2026-09-24 19:56:48 +08:00
parent 3e337722ab
commit ba920f2b66
183 changed files with 49496 additions and 9934 deletions
+159 -127
View File
@@ -1,11 +1,20 @@
"""
K3 动作控制 自研节点
K3 动作控制节点
用参考视频驱动参考图中人物动作,生成视频。
视频通过 R2 上传后传 URL,图片转 base64 直传。
支持的模型:
- v3:标准动作控制,支持 5~30s 时长
- v2-6:标准动作控制,支持 5~30s 时长
- v3-t / v2-6-t:腾讯 Kling 网关渠道(保留兼容)
接口端点:
- 动作控制:POST /kling/v1/videos/motion-control
- 腾讯渠道:POST /v1/videos
"""
import asyncio
import io
import io as _stdio
import json
import os
import struct
@@ -13,12 +22,16 @@ import tempfile
import aiohttp
from ..utils.config import get_api_key_or_raise, get_async_api_base_url, NETWORK_ROUTE_OPTIONS, get_base_url_by_route
from comfy_api.latest import io
from ..utils.config import get_api_key_or_raise, get_async_api_base_url, get_base_url_by_route
from ..utils.r2_uploader import upload_video, upload_image
from ..utils.image_utils import tensor_to_pil
from ..utils.http_error import async_request_with_retry
from ..utils.video_task import (
PollDeadline,
check_interrupt,
download_video_to_file,
extract_error_message,
extract_progress,
extract_status,
@@ -39,9 +52,26 @@ except Exception:
# ── 常量 ──────────────────────────────────────────────────────────────────────
# 官方标准模型名映射
_STANDARD_MODELS = {
"v3": "kling-v3",
"v2-6": "kling-v2-6",
}
# 官方标准端点
_ENDPOINT_CREATE = "/kling/v1/videos/motion-control"
_ENDPOINT_STATUS = "/kling/v1/videos/motion-control/{task_id}"
# 腾讯 Kling 网关渠道(-t):保留兼容
_ENDPOINT_T_CREATE = "/v1/videos"
_ENDPOINT_T_STATUS = "/v1/videos/{task_id}"
# -t 渠道模型名映射(服务端已部署,需在「模型倍率」各配一行 =1)
_MODEL_T_MAP = {
"v3-t": "kling-v3-motion-t",
"v2-6-t": "kling-v2-6-motion-t",
}
_POLL_INIT = 5
_POLL_MAX = 15
@@ -80,7 +110,7 @@ def _get_video_duration(reference_video) -> float | None:
if isinstance(source, str) and os.path.isfile(source):
with open(source, "rb") as f:
data = f.read()
elif isinstance(source, io.BytesIO):
elif isinstance(source, _stdio.BytesIO):
source.seek(0)
data = source.read()
else:
@@ -106,51 +136,79 @@ def _validate_video_duration(reference_video, character_orientation: str):
)
# ── 模型 DynamicCombo 选项构建 ─────────────────────────────────────────────────
def _build_model_input():
"""构建「模型」DynamicCombo。
支持的模型:
- v3:标准动作控制
- v2-6:标准动作控制
- v3-t / v2-6-t:腾讯网关渠道(保留兼容)
"""
def _duration_input():
return io.Combo.Input(
"时长", options=[5, 10, 15, 20, 25, 30], default=5,
tooltip="输出视频时长(秒)。须 ≥ 参考视频时长。",
)
return io.DynamicCombo.Input(
"模型",
options=[
io.DynamicCombo.Option("v3", [_duration_input()]),
io.DynamicCombo.Option("v2-6", [_duration_input()]),
io.DynamicCombo.Option("v3-t", []), # 腾讯网关,无时长参数
io.DynamicCombo.Option("v2-6-t", []), # 腾讯网关,无时长参数
],
tooltip="v3/v2-6:官方标准模型;v3-t/v2-6-t:腾讯网关渠道(兼容)。",
)
# ── 节点 ──────────────────────────────────────────────────────────────────────
class K3MotionControl:
class K3MotionControl(io.ComfyNode):
"""K3 动作控制 自研 —— 用参考视频驱动参考图人物动作"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"参考图片": ("IMAGE",),
"参考视频": ("VIDEO",),
"提示词": ("STRING", {"multiline": True, "default": ""}),
"网络线路": (NETWORK_ROUTE_OPTIONS, {"default": "全球加速"}),
"模型": (["v3", "v2-6"], {"default": "v3"}),
"模式": (["720p", "1080p"], {"default": "1080p"}),
"时长": ([5, 10, 15, 20, 25, 30], {"default": 5}),
"角色朝向": (["图片", "视频"], {"default": "图片"}),
"保留原声": (["打开", "关闭"], {"default": "打开"}),
"seed": ("INT", {
"default": 0, "min": 0, "max": 2147483647,
"tooltip": "seed 仅控制节点是否重新运行,结果本身不可复现。",
}),
},
}
def define_schema(cls):
return io.Schema(
node_id="K3MotionControl",
display_name="K 动作模仿",
category="comfyui_o1key/KVideo",
inputs=[
io.Image.Input("参考图片"),
io.Video.Input("参考视频"),
_build_model_input(),
io.String.Input("提示词", multiline=True, default=""),
io.Combo.Input("模式", options=["720p", "1080p"], default="1080p"),
io.Combo.Input("角色朝向", options=["图片", "视频"], default="图片"),
io.Combo.Input("保留原声", options=["打开", "关闭"], default="打开"),
io.Int.Input("seed", default=0, min=0, max=2147483647,
tooltip="seed 仅控制节点是否重新运行,结果本身不可复现。"),
],
outputs=[io.Video.Output(display_name="视频")],
accept_all_inputs=True,
)
RETURN_TYPES = ("VIDEO",)
RETURN_NAMES = ("视频",)
FUNCTION = "generate"
CATEGORY = "comfyui_o1key/KVideo"
async def generate(self, 参考图片, 参考视频, 提示词, 保留原声, 角色朝向, 模式, 模型, 时长, 网络线路, seed, **kwargs):
@classmethod
async def execute(cls, 参考图片, 参考视频, 模型, 提示词, 模式, 角色朝向, 保留原声, seed, **_kwargs) -> io.NodeOutput:
api_key = get_api_key_or_raise()
base_url = get_base_url_by_route(网络线路)
# ── 渠道判定(模型为 DynamicCombo dict)─────────────────────────
模型代号 = 模型["模型"]
is_t_channel = 模型代号 in _MODEL_T_MAP
时长 = int(模型.get("时长", 5)) # -t 渠道无此子输入
mode_api = "std" if 模式 == "720p" else "pro"
character_orientation = "image" if 角色朝向 == "图片" else "video"
keep_sound = "yes" if 保留原声 == "打开" else "no"
prompt = 提示词.strip()
base_url = get_base_url_by_route()
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
# ── 参数映射 ──────────────────────────────────────────────────
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"
prompt = 提示词.strip()
if len(prompt) > 2500:
raise ValueError(f"提示词长度({len(prompt)})超过上限 2500 个字符,请缩短后重试。")
@@ -163,7 +221,7 @@ class K3MotionControl:
def _stage(s: str):
if s == "uploading":
print("[K3 动作控制] 上传视频到 R2...")
print("[K3 动作控制] 上传图片/视频到 OSS...")
if pbar: pbar.update_absolute(0, 100)
elif s == "submitting":
print("[K3 动作控制] 提交任务...")
@@ -182,36 +240,65 @@ class K3MotionControl:
if pbar: pbar.update_absolute(15 + int(pct * 0.84), 100)
# ── 视频时长校验 ──────────────────────────────────────────────
# 参考视频时长约束(image≤10s / video≤30s,下限 3s)两渠道通用。
_validate_video_duration(参考视频, character_orientation)
# 参考视频时长不得超过所选时长(防止用长视频生成短计费)
_dur = _get_video_duration(参考视频)
if _dur is not None and _dur > 时长 + 0.5:
raise ValueError(
f"参考视频时长 {_dur:.1f}s 超过所选时长 {时长}s。\n"
f"请将时长调整为 ≥{_dur:.0f}s 的档位,或更换更短的参考视频。"
)
# 参考视频不得超过所选时长」仅标准渠道有意义:-t 渠道无时长入参
if not is_t_channel:
_dur = _get_video_duration(参考视频)
if _dur is not None and _dur > 时长 + 0.5:
raise ValueError(
f"参考视频时长 {_dur:.1f}s 超过所选时长 {时长}s。\n"
f"请将时长调整为 ≥{_dur:.0f}s 的档位,或更换更短的参考视频。"
)
# ── 图片 & 视频上传 R2 → 获取公网 URL ────────────────────────
# ── 图片 & 视频上传 OSS → 获取公网 URL ────────────────────────
_stage("uploading")
check_interrupt()
pil_list = tensor_to_pil(参考图片)
image_url = await upload_image(pil_list[0].convert("RGB"))
img = pil_list[0]
# 转换为 RGBA 以支持透明通道,PNG 格式上传
if img.mode not in ("RGBA", "RGB"):
img = img.convert("RGBA" if "A" in img.mode or img.mode == "LA" else "RGB")
image_url = await upload_image(img, base_url=base_url)
check_interrupt()
video_url = await upload_video(参考视频)
video_url = await upload_video(参考视频, base_url=base_url)
# ── 构建请求体 ────────────────────────────────────────────────
body: dict = {
"model_name": model_name,
"model": model_name,
"image_url": image_url,
"video_url": video_url,
"character_orientation": character_orientation,
"mode": mode_api,
"keep_original_sound": keep_sound,
}
if prompt:
body["prompt"] = prompt
if is_t_channel:
# 腾讯 Kling 网关渠道:动作控制专有字段进 metadata 透传(PascalCase),
# 顶层只放标准字段。无 duration / mode 由网关按模型处理。
body = {
"model": _MODEL_T_MAP[模型代号],
"prompt": prompt or "动作与参考视频保持一致", # 网关强制非空
"image": image_url,
"metadata": {
"Video": video_url,
"CharacterOrientation": character_orientation,
"KeepOriginalSound": keep_sound,
"Mode": mode_api, # 720p→std / 1080p→pro
},
}
create_path = _ENDPOINT_T_CREATE
status_path = _ENDPOINT_T_STATUS
else:
# 标准渠道:使用官方标准接口,参数扁平传递
# 获取实际的模型名(v3 → kling-v3
actual_model_name = _STANDARD_MODELS.get(模型代号, f"kling-{模型代号}")
body = {
"model_name": actual_model_name,
"image_url": image_url,
"video_url": video_url,
"character_orientation": character_orientation,
"mode": mode_api,
"keep_original_sound": keep_sound,
"duration": str(时长),
}
if prompt:
body["prompt"] = prompt
create_path = _ENDPOINT_CREATE
status_path = _ENDPOINT_STATUS
# ── 保存路径(临时文件,避免与下游保存节点重复落盘)──────────────────
tmp_fd, save_path = tempfile.mkstemp(suffix=".mp4", prefix="k3_motion_")
@@ -222,7 +309,7 @@ class K3MotionControl:
# 1. 提交任务
check_interrupt()
_stage("submitting")
create_url = f"{base_url}{_ENDPOINT_CREATE}"
create_url = f"{base_url}{create_path}"
resp = await run_with_interrupt(async_request_with_retry(
session, "POST", create_url,
data=json.dumps(body, ensure_ascii=False).encode("utf-8"),
@@ -243,11 +330,13 @@ class K3MotionControl:
_stage(f"submitted:{task_id}")
# 2. 轮询
status_url = f"{base_url}{_ENDPOINT_STATUS.format(task_id=task_id)}"
status_url = f"{base_url}{status_path.format(task_id=task_id)}"
interval = _POLL_INIT
video_result_url = None
deadline = PollDeadline(label="K3 动作控制")
while True:
deadline.check()
await interruptible_sleep(interval)
check_interrupt()
async with session.get(status_url, headers=headers) as resp:
@@ -281,84 +370,27 @@ class K3MotionControl:
if not video_result_url:
raise RuntimeError(f"API 未返回视频 URL,响应:{sr}")
# 3. 下载视频
# 3. 下载视频(抗超时 / 断点续传 / 无限重试 / 可取消)
check_interrupt()
_stage("downloading")
async with session.get(video_result_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):
check_interrupt()
f.write(chunk)
os.close(tmp_fd)
await download_video_to_file(
session, video_result_url, save_path, label="K3 动作控制",
)
_stage("done")
if _FOLDER_PATHS_OK:
return (InputImpl.VideoFromFile(save_path),)
return (save_path,)
# ── 视频时长检测测试节点 ──────────────────────────────────────────────────────
class K3MotionVideoCheck:
"""检测视频时长并校验是否满足动作控制的限制,不调用 API。"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"参考视频": ("VIDEO",),
"角色朝向": (["图片", "视频"], {"default": "图片"}),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("检测结果",)
FUNCTION = "check"
CATEGORY = "comfyui_o1key/KVideo"
OUTPUT_NODE = True
def check(self, 参考视频, 角色朝向):
character_orientation = "image" if 角色朝向 == "图片" else "video"
duration = _get_video_duration(参考视频)
if duration is None:
result = "❌ 无法解析视频时长(格式不支持或文件损坏)"
print(f"[K3 视频检测] {result}")
return (result,)
limit = 10 if character_orientation == "image" else 30
orientation_label = 角色朝向
ok = 3 <= duration <= limit
if ok:
result = (
f"✅ 时长检测通过\n"
f"视频时长: {duration:.2f}s\n"
f"角色朝向: {orientation_label}(限制 3~{limit}s"
)
else:
result = (
f"❌ 时长检测不通过\n"
f"视频时长: {duration:.2f}s\n"
f"角色朝向: {orientation_label}(限制 3~{limit}s\n"
f"请更换时长在 3~{limit}s 之间的视频。"
)
print(f"[K3 视频检测] {result}")
return (result,)
return io.NodeOutput(InputImpl.VideoFromFile(save_path))
return io.NodeOutput(save_path)
# ── 节点注册 ──────────────────────────────────────────────────────────────────
NODE_CLASS_MAPPINGS = {
"K3MotionControl": K3MotionControl,
"K3MotionVideoCheck": K3MotionVideoCheck,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"K3MotionControl": "动作控制 K3 自研",
"K3MotionVideoCheck": "视频时长检测 K3",
"K3MotionControl": "K 动作模仿",
}