Files
comfyui_o1key/nodes/K3_video.py
T
Jony ba920f2b66 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.
2026-09-24 19:56:48 +08:00

1092 lines
46 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
K 图生视频节点(图生视频 / 首尾帧 / 多分镜)
支持的模型:
- kling-v3:标准图生视频,支持 3~15s 时长,std/pro/4K 模式
- kling-v2-6:标准图生视频,支持 5/10s 时长,std/pro 模式
接口端点:
- 图生视频/首尾帧:POST /kling/v1/videos/image2video
模型能力:
- v3:时长 3~15s;模式 std/pro/4K;音频全支持
- v2-6:时长仅 5/10s;模式仅 std/pro(无 4K);有首尾帧的 pro 模式只能生成无声视频
"""
import io as stdlib_io
import json
import os
import re
import shutil
import subprocess
import tempfile
import aiohttp
from comfy_api.latest import io
from ..utils.config import get_api_key_or_raise, get_base_url_by_route
from ..utils.image_utils import tensor_to_pil
from ..utils.r2_uploader import upload_image
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,
extract_video_url,
interruptible_sleep,
is_failure_status,
is_success_status,
run_with_interrupt,
)
try:
from comfy_api.latest import InputImpl
_FOLDER_PATHS_OK = True
except Exception:
_FOLDER_PATHS_OK = False
# ── 常量 ──────────────────────────────────────────────────────────────────────
_MODES = ["720p", "1080p", "4K"]
_MODES_V26 = ["720p", "1080p"] # v2.6 无 4K
_MODE_MAP = {"720p": "std", "1080p": "pro", "4K": "4k"}
# 官方标准模型名(不再拼接参数)
_STANDARD_MODELS = {
"v3": "kling-v3",
"v2-6": "kling-v2-6",
"v3-omni": "kling-v3-omni",
}
# 腾讯网关渠道(-t)模型名:服务端已部署,需在「模型倍率」各配一行
_T_MODEL_MAP = {"v3-t": "kling-v3-t", "v2-6-t": "kling-v2-6-t"}
# v2.6 能力约束
_V26_DURATIONS = (5, 10) # 仅支持 5、10s
_MAX_SHOTS = 6
_DURATIONS = list(range(3, 16)) # 3~15sv3 支持范围)
_V26_DURATION_OPTIONS = [5, 10] # v2.6 时长选项
# 官方标准端点
_ENDPOINT_IMAGE2VIDEO_CREATE = "/kling/v1/videos/image2video"
_ENDPOINT_IMAGE2VIDEO_STATUS = "/kling/v1/videos/image2video/{task_id}"
_ENDPOINT_OMNI_CREATE = "/kling/v1/videos/omni-video"
_ENDPOINT_OMNI_STATUS = "/kling/v1/videos/omni-video/{task_id}"
# 腾讯网关端点(保留兼容)
_ENDPOINT_V3T_CREATE = "/v1/videos"
_ENDPOINT_V3T_STATUS = "/v1/videos/{task_id}"
_POLL_INIT = 3
_POLL_MAX = 15
# 主体(Element):在提示词中用【@名称】内联引用,提交时解析为 element_id 注入 metadata.ElementList。
# 形如【@模特B】,名称即创建主体时填的 name(≤20 字)。提交前查 /mine 取 名称→element_id。
# 名称允许中英文、数字、空格、下划线、连字符,遇到右括号】或@结束。
_ELEMENT_RE = re.compile(r"【@([^】]+)】")
_MAX_ELEMENTS = 3 # 官方约束:最多 3 个参考主体
_REFERENCE_VIDEO_MAX_BYTES = 200 * 1024 * 1024
_REFERENCE_VIDEO_MIN_DURATION = 3.0
_REFERENCE_VIDEO_MAX_DURATION_BY_MODEL = {
"v3-omni": 15.5,
}
_REFERENCE_VIDEO_MIN_SIZE = 720
_REFERENCE_VIDEO_MAX_SIZE = 2160
_REFERENCE_VIDEO_MIN_FPS = 24.0
_REFERENCE_VIDEO_MAX_FPS = 60.0
_REFERENCE_VIDEO_MIN_RATIO = 1 / 2.5
_REFERENCE_VIDEO_MAX_RATIO = 2.5
# ── 工具函数 ───────────────────────────────────────────────────────────────────
def _extract_elements(text: str):
"""从文本中提取【@名称】主体引用,返回 (原文本, 去重后的名称列表)。
与 @elem_id 方案不同:这里保留【@名称】在 prompt 中(官方要求模型读到点名),
仅把名称收集出来,提交时再查 /mine 映射成 element_id。
"""
if not text:
return text, []
names = []
for m in _ELEMENT_RE.finditer(text):
name = m.group(1).strip()
if name and name not in names:
names.append(name)
return text, names
def _prepare_image_for_upload(tensor):
"""转换并校验图片,不符合约束时自动等比缩放后返回 PIL Image。"""
import io as _io
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) # LANCZOS=1
# 3. 文件大小:循环等比缩小直到 ≤ 10MB
MAX_BYTES = 10 * 1024 * 1024
for _ in range(20): # 最多迭代 20 次,防止死循环
buf = _io.BytesIO()
img.save(buf, format="PNG")
if buf.tell() <= MAX_BYTES:
break
scale = (MAX_BYTES / buf.tell()) ** 0.5 * 0.95 # 留 5% 余量
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,请检查原始图片。")
return img
def _resolve_reference_video_path(video) -> str:
"""从 ComfyUI VIDEO 对象中取本地文件路径,用于先校验再上传。"""
candidates = []
def _add(value):
if value is None:
return
if isinstance(value, dict):
candidates.append(value)
for key in (
"video", "path", "file", "filename", "source_path", "source",
"fullpath", "full_path", "filepath", "file_path",
):
if value.get(key):
_add(value.get(key))
return
candidates.append(value)
if hasattr(video, "get_stream_source"):
try:
_add(video.get_stream_source())
except Exception:
pass
_add(video)
for attr in (
"source_path", "path", "video", "file", "filename", "source",
"fullpath", "full_path", "filepath", "file_path",
):
if hasattr(video, attr):
try:
_add(getattr(video, attr))
except Exception:
pass
for source in candidates:
if isinstance(source, stdlib_io.BytesIO):
fd, tmp = tempfile.mkstemp(suffix=".mp4", prefix="k3_ref_video_")
os.close(fd)
source.seek(0)
with open(tmp, "wb") as f:
f.write(source.read())
return tmp
if isinstance(source, dict) and source.get("filename"):
filename = str(source.get("filename") or "").strip()
subfolder = str(source.get("subfolder") or "").strip()
file_type = str(source.get("type") or "input").strip().lower()
try:
import folder_paths
base_getters = {
"input": folder_paths.get_input_directory,
"output": folder_paths.get_output_directory,
"temp": folder_paths.get_temp_directory,
"temporary": folder_paths.get_temp_directory,
}
getter = base_getters.get(file_type, folder_paths.get_input_directory)
base_dir = getter()
path = os.path.abspath(os.path.join(base_dir, subfolder, filename))
if os.path.isfile(path):
return path
except Exception:
pass
try:
raw_path = os.fspath(source).strip().strip('"').strip("'")
except Exception:
continue
if not raw_path:
continue
paths = [raw_path]
if not os.path.isabs(raw_path):
paths.append(os.path.abspath(raw_path))
try:
import folder_paths
for base_dir in (
folder_paths.get_input_directory(),
folder_paths.get_output_directory(),
folder_paths.get_temp_directory(),
):
paths.append(os.path.abspath(os.path.join(base_dir, raw_path)))
except Exception:
pass
for video_path in paths:
if os.path.isfile(video_path):
return video_path
if isinstance(video, dict):
detail = f"dict keys={list(video.keys())}"
else:
attrs = [
name for name in (
"source_path", "path", "video", "file", "filename", "source",
"fullpath", "full_path", "filepath", "file_path",
)
if hasattr(video, name)
]
detail = f"type={type(video).__name__}, attrs={attrs}"
raise RuntimeError(f"无法获取参考视频文件路径:None({detail}")
def _describe_reference_video_input(video) -> str:
if video is None:
return "None"
if isinstance(video, dict):
return f"dict keys={list(video.keys())}"
attrs = [
name for name in (
"source_path", "path", "video", "file", "filename", "source",
"fullpath", "full_path", "filepath", "file_path",
)
if hasattr(video, name)
]
stream_source = None
if hasattr(video, "get_stream_source"):
try:
stream_source = video.get_stream_source()
except Exception as e:
stream_source = f"<get_stream_source error: {e}>"
return f"type={type(video).__name__}, attrs={attrs}, stream_source={stream_source!r}"
def _parse_fps(value):
if value in (None, "", "0/0", "N/A"):
return None
try:
text = str(value)
if "/" in text:
numerator, denominator = text.split("/", 1)
denominator = float(denominator)
if denominator == 0:
return None
return float(numerator) / denominator
return float(text)
except Exception:
return None
def _probe_video_with_ffprobe(video_path: str):
ffprobe = shutil.which("ffprobe")
if not ffprobe:
return None
cmd = [
ffprobe,
"-v", "error",
"-select_streams", "v:0",
"-show_entries", "stream=width,height,avg_frame_rate,r_frame_rate,duration:format=duration",
"-of", "json",
video_path,
]
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=20,
check=False,
)
if result.returncode != 0:
return None
data = json.loads(result.stdout or "{}")
streams = data.get("streams") or []
stream = streams[0] if streams else {}
fmt = data.get("format") or {}
fps = _parse_fps(stream.get("avg_frame_rate")) or _parse_fps(stream.get("r_frame_rate"))
duration = stream.get("duration") or fmt.get("duration")
return {
"width": int(stream.get("width") or 0),
"height": int(stream.get("height") or 0),
"fps": float(fps) if fps else None,
"duration": float(duration) if duration not in (None, "", "N/A") else None,
}
except Exception:
return None
def _probe_video_with_cv2(video_path: str):
try:
import cv2
except Exception:
return None
cap = cv2.VideoCapture(video_path)
try:
if not cap.isOpened():
return None
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
fps = float(cap.get(cv2.CAP_PROP_FPS) or 0) or None
frame_count = float(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
duration = (frame_count / fps) if fps and frame_count > 0 else None
return {
"width": width,
"height": height,
"fps": fps,
"duration": duration,
}
finally:
cap.release()
def _validate_reference_video(video_path: str, model: str) -> None:
"""校验 Kling omni 参考视频约束,通过后才能上传到 R2。"""
ext = os.path.splitext(video_path)[1].lower()
if ext not in (".mp4", ".mov"):
raise RuntimeError(f"参考视频格式仅支持 MP4/MOV,当前为 {ext or '无扩展名'}。")
size = os.path.getsize(video_path)
if size > _REFERENCE_VIDEO_MAX_BYTES:
raise RuntimeError(
f"参考视频大小不能超过 200MB,当前为 {size / 1024 / 1024:.1f}MB。"
)
meta = _probe_video_with_ffprobe(video_path) or _probe_video_with_cv2(video_path)
if not meta:
raise RuntimeError("无法读取参考视频信息,请确认视频文件可正常播放,并安装 ffprobe 或 OpenCV 后重试。")
width = int(meta.get("width") or 0)
height = int(meta.get("height") or 0)
fps = meta.get("fps")
duration = meta.get("duration")
if not width or not height or not fps or not duration:
raise RuntimeError(
"参考视频信息不完整,无法校验时长、分辨率或帧率。"
"请确认视频文件可正常播放,并安装 ffprobe 或 OpenCV 后重试。"
)
max_duration = _REFERENCE_VIDEO_MAX_DURATION_BY_MODEL.get(model, 10.0)
if duration < _REFERENCE_VIDEO_MIN_DURATION or duration > max_duration:
raise RuntimeError(
f"参考视频时长需在 {_REFERENCE_VIDEO_MIN_DURATION:.0f}~{max_duration:.0f}s 之间,当前为 {duration:.2f}s。"
)
if not (
_REFERENCE_VIDEO_MIN_SIZE <= width <= _REFERENCE_VIDEO_MAX_SIZE
and _REFERENCE_VIDEO_MIN_SIZE <= height <= _REFERENCE_VIDEO_MAX_SIZE
):
raise RuntimeError(
f"参考视频宽高尺寸需均在 720px~2160px 之间,当前为 {width}x{height}。"
)
if fps < _REFERENCE_VIDEO_MIN_FPS or fps > _REFERENCE_VIDEO_MAX_FPS:
raise RuntimeError(
f"参考视频帧率需在 24~60fps 之间,当前为 {fps:.2f}fps。"
)
ratio = width / height
if ratio < _REFERENCE_VIDEO_MIN_RATIO or ratio > _REFERENCE_VIDEO_MAX_RATIO:
raise RuntimeError(
f"参考视频宽高比 {width}:{height}{ratio:.2f})超出允许范围 1:2.5 ~ 2.5:1。"
)
print(
"[K3 图生视频] 参考视频校验通过:"
f"{width}x{height}, {duration:.2f}s, {fps:.2f}fps, {size / 1024 / 1024:.1f}MB"
)
def _collect_shots(分镜模式: dict, 时长: int) -> list:
"""从 DynamicCombo 的 dict 收集分镜参数并校验。
返回 [{"index","prompt","duration"}...];选「禁用」时返回 []。
"""
选择 = 分镜模式.get("分镜模式", "禁用")
if 选择 == "禁用":
return []
shot_count = int(选择[0]) # "3个故事板" → 3
shots = []
for i in range(1, shot_count + 1):
p = (分镜模式.get(f"分镜{i}_提示词") or "").strip()
d = int(分镜模式.get(f"分镜{i}_时长") or 0)
if not p:
raise RuntimeError(f"分镜模式错误:第 {i} 段分镜提示词不能为空。")
if len(p) > 512:
raise RuntimeError(f"分镜模式错误:第 {i} 段分镜提示词超过 512 字符。")
if d < 1 or d > 时长:
raise RuntimeError(f"分镜模式错误:第 {i} 段分镜时长须在 1~{时长}s 之间。")
shots.append({"index": i, "prompt": p, "duration": d})
total = sum(int(s["duration"]) for s in shots)
if total != 时长:
raise RuntimeError(f"分镜模式错误:各分镜时长之和({total}s)必须等于总时长({时长}s)。")
return shots
def _validate_v26(模型: str, 时长: int, 模式: str, 生成音频: str, 尾帧) -> None:
"""v2.6v2-6 / v2-6-t)能力约束校验,不符合时抛出清晰错误。
时长仅 5/10s、模式仅 720p/1080p(无 4K)已由「模型」DynamicCombo 在 UI 层
限定,此处的时长/模式判断仅作为防御性兜底;主要校验无法用结构表达的
「有首尾帧的 pro(1080p)模式只能无声」组合(依赖音频/尾帧/模式三者)。
"""
if 模型 not in ("v2-6", "v2-6-t"):
return
if 时长 not in _V26_DURATIONS:
raise RuntimeError(
f"{模型} 模型不支持 {时长}s 时长,仅支持 5s 或 10s,请调整后重试。"
)
if 模式 == "4K":
raise RuntimeError(
f"{模型} 模型不支持 4K 模式,仅支持 720p / 1080p,请切换模式后重试。"
)
if 模式 == "1080p" and 尾帧 is not None and 生成音频 == "打开":
raise RuntimeError(
f"{模型} 模型在「1080p(pro)+ 首尾帧」时只能生成无声视频,"
"请关闭生成音频,或移除尾帧后重试。"
)
async def _run_video_task(base_url: str, headers: dict, body: dict,
create_path: str, status_path: str, prefix: str):
"""提交 → 轮询 → 下载,返回本地 mp4 路径。两渠道共用。
create_path/status_path 为端点模板(status_path 含 {task_id})。
"""
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)
tmp_fd, save_path = tempfile.mkstemp(suffix=".mp4", prefix="k3_")
connector = aiohttp.TCPConnector(ssl=False, force_close=True)
async with aiohttp.ClientSession(connector=connector) as session:
# 1. 提交
check_interrupt()
_stage("submitting")
create_url = f"{base_url}{create_path}"
resp = await run_with_interrupt(async_request_with_retry(
session, "POST", create_url, json=body, headers=headers, prefix=prefix
))
check_interrupt()
create_resp = json.loads(await resp.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}{status_path.format(task_id=task_id)}"
interval = _POLL_INIT
deadline = PollDeadline(label="K3 图生视频")
video_url = None
while True:
deadline.check()
check_interrupt()
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)
status = extract_status(sr)
pct = extract_progress(sr)
print(f"[K3 图生视频] 生成中 {pct}%")
_progress(pct)
if is_success_status(status):
video_url = extract_video_url(sr)
break
if is_failure_status(status, sr):
raise RuntimeError(f"K3 生成失败:{extract_error_message(sr)}")
await interruptible_sleep(interval)
interval = min(interval * 1.5, _POLL_MAX)
if not video_url:
raise RuntimeError(f"API 未返回视频 URL,响应:{sr}")
# 3. 下载(抗超时 / 断点续传 / 无限重试 / 可取消)
check_interrupt()
_stage("downloading")
os.close(tmp_fd)
await download_video_to_file(session, video_url, save_path, label="K3 图生视频")
_stage("done")
return save_path
# ── 模型 DynamicCombo 选项构建 ────────────────────────────────────────────────
def _v3_omni_subs():
"""v3-omni 模型的子输入。"""
return [
io.Combo.Input("时长", options=_DURATIONS, default=5),
io.Combo.Input("模式", options=_MODES, default="720p"),
io.Combo.Input(
"宽高比",
options=["智能", "16:9", "9:16", "1:1"],
default="智能",
tooltip="智能:有首帧或待编辑视频时自动推断;纯文本/无首帧生成时需选择明确比例。",
),
io.Combo.Input(
"视频参考类型",
options=["不使用", "特征参考", "待编辑视频"],
default="不使用",
tooltip="不使用:未接入参考视频;特征参考:提取视频特征作为参考;待编辑视频:直接编辑该视频内容。",
),
io.Combo.Input(
"保留视频原声",
options=["否", "是"],
default="否",
tooltip="是否保留参考视频中的原始音频。",
),
]
def _build_model_input():
"""构建「模型」DynamicCombo,把「时长」「模式」作为随模型变化的子输入。
各模型只暴露其实际支持的档位:
- v3:时长 3~15s;模式 720p/1080p/4K
- v2-6:时长 5/10s;模式 720p/1080p(无 4K
- v3-omni:时长 3~15s;模式 720p/1080p/4K;支持多模态参考
"""
def _v3_subs():
return [
io.Combo.Input("时长", options=_DURATIONS, default=5),
io.Combo.Input("模式", options=_MODES, default="720p"),
]
def _v26_subs():
return [
io.Combo.Input("时长", options=_V26_DURATION_OPTIONS, default=5),
io.Combo.Input("模式", options=_MODES_V26, default="720p"),
]
def _v3t_subs():
return [
io.Combo.Input("时长", options=_DURATIONS, default=5),
io.Combo.Input("模式", options=_MODES, default="720p"),
]
return io.DynamicCombo.Input(
"模型",
options=[
io.DynamicCombo.Option("v3-omni", _v3_omni_subs()),
io.DynamicCombo.Option("v2-6", _v26_subs()),
io.DynamicCombo.Option("v3", _v3_subs()),
],
tooltip="v2-6: 5/10s; v3: 3-15s; v3-omni: 多模态参考、视频编辑、主体管理",
)
# ── 分镜模式 DynamicCombo 选项构建 ────────────────────────────────────────────────
def _build_multishot_input():
"""构建「分镜模式」DynamicCombo。
- 禁用:显示「正向提示词」(单段图生视频用);
- N个故事板:隐藏正向提示词,动态显示 N 组分镜输入。
(腾讯文档:MultiShot=true 时主 Prompt 无效,故选故事板后隐藏正向提示词)
"""
disabled = [io.String.Input("正向提示词", multiline=True, default="",
tooltip="单段模式的正向提示词。选故事板后由各分镜提示词替代。")]
options = [io.DynamicCombo.Option("禁用", disabled)]
for n in range(1, _MAX_SHOTS + 1):
sub = []
for i in range(1, n + 1):
sub.append(io.String.Input(
f"分镜{i}_提示词", multiline=True, default="",
tooltip=f"第 {i} 段分镜提示词,最多 512 字符。",
))
sub.append(io.Int.Input(
f"分镜{i}_时长", default=4, min=1, max=15,
display_mode=io.NumberDisplay.slider,
tooltip=f"第 {i} 段分镜时长(秒)。各分镜时长之和须等于总时长。",
))
options.append(io.DynamicCombo.Option(f"{n}个故事板", sub))
return io.DynamicCombo.Input(
"分镜模式", options=options,
tooltip="禁用:单段图生视频(显示正向提示词);N个故事板:动态显示 N 段分镜输入。",
)
class K3Video(io.ComfyNode):
"""K 视频生成 自研(V3,动态分镜模式)"""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="K3Video",
display_name="K 视频生成",
category="comfyui_o1key/KVideo",
inputs=[
# 模型(DynamicCombo):内含随模型变化的「时长」「模式」子输入
_build_model_input(),
# 分镜模式(含动态正向提示词/分镜框)紧跟模型下方
_build_multishot_input(),
io.Image.Input("起始帧", optional=True,
tooltip="作为视频首帧(type=first_frame)。与尾帧一起使用时触发首尾帧生视频。"),
io.Image.Input("尾帧", optional=True,
tooltip="作为视频尾帧(type=end_frame)。必须与起始帧一起使用。注意:有尾帧时不能使用参考图2-5。与分镜模式互斥。"),
io.Image.Input("参考图2", optional=True,
tooltip="仅 v3-omni 使用。作为场景/风格/主体参考图(无 type 字段)。与尾帧互斥。"),
io.Image.Input("参考图3", optional=True,
tooltip="仅 v3-omni 使用。作为场景/风格/主体参考图(无 type 字段)。与尾帧互斥。"),
io.Image.Input("参考图4", optional=True,
tooltip="仅 v3-omni 使用。作为场景/风格/主体参考图(无 type 字段)。与尾帧互斥。"),
io.Image.Input("参考图5", optional=True,
tooltip="仅 v3-omni 使用。作为场景/风格/主体参考图(无 type 字段)。与尾帧互斥。"),
io.Video.Input("参考视频", optional=True,
tooltip="仅 v3-omni 使用。MP4/MOV3-15.5秒,≤200MB,宽高720-2160px24-60fps,宽高比1:2.5~2.5:1。"),
io.Combo.Input("生成音频", options=["关闭", "打开"], default="关闭"),
io.Int.Input("seed", default=0, min=0, max=2147483647,
tooltip="seed 仅控制节点是否重新运行,结果本身不可复现。"),
# 负向提示词置于最下方(全局生效,两种模式都可用)
io.String.Input("负向提示词", multiline=True, default=""),
],
outputs=[io.Video.Output(display_name="视频")],
accept_all_inputs=True,
)
@classmethod
async def execute(cls, 模型, 负向提示词, 生成音频, seed, 分镜模式,
起始帧=None, 尾帧=None, 参考图2=None, 参考图3=None, 参考图4=None, 参考图5=None,
参考视频=None, **_kwargs) -> io.NodeOutput:
# 模型为 DynamicCombo dict:取模型代号及随之变化的「时长」「模式」子输入
模型代号 = 模型["模型"]
时长 = int(模型.get("时长", 5))
模式 = 模型.get("模式", "720p")
宽高比 = 模型.get("宽高比", "智能")
视频参考类型 = 模型.get("视频参考类型", "不使用")
保留视频原声 = 模型.get("保留视频原声", "否")
base_url = get_base_url_by_route()
headers = {
"Authorization": f"Bearer {get_api_key_or_raise()}",
"Content-Type": "application/json",
}
shots = _collect_shots(分镜模式, 时长)
# 正向提示词仅在「禁用」(单段)时存在;选故事板后腾讯忽略主 Prompt,故隐藏。
提示词 = (分镜模式.get("正向提示词") or "") if not shots else ""
# 主体引用:从正向提示词 + 各分镜提示词中提取【@名称】,汇总去重(保留原文不清理)。
element_names = []
_, _names = _extract_elements(提示词)
for n in _names:
if n not in element_names:
element_names.append(n)
for s in shots:
_, _names = _extract_elements(s["prompt"])
for n in _names:
if n not in element_names:
element_names.append(n)
if len(element_names) > _MAX_ELEMENTS:
raise RuntimeError(
f"引用主体过多({len(element_names)} 个),官方最多支持 {_MAX_ELEMENTS} 个,请减少【@名称】引用。"
)
if element_names and 模型代号 != "v3-omni":
raise RuntimeError(
f"主体引用(【@名称】)仅 v3-omni 支持,当前模型 {模型代号} 不支持,"
"请切换模型,或移除提示词中的【@名称】引用。"
)
element_ids = []
if element_names:
element_ids = await cls._resolve_element_ids(element_names)
ref_images = [参考图2, 参考图3, 参考图4, 参考图5]
has_ref_images = any(img is not None for img in ref_images)
has_omni_inputs = has_ref_images or 参考视频 is not None or 视频参考类型 != "不使用"
if 模型代号 != "v3-omni" and has_omni_inputs:
raise RuntimeError(
f"参考图、参考视频和视频参考类型仅 v3-omni 支持,当前模型 {模型代号} 不支持。"
"请切换模型为 v3-omni,或移除多模态参考输入。"
)
# v3-omni 官方约束:图片数量 + 主体数量约束
ref_image_count = sum([
1 if 起始帧 is not None else 0,
1 if 尾帧 is not None else 0,
1 if 参考图2 is not None else 0,
1 if 参考图3 is not None else 0,
1 if 参考图4 is not None else 0,
1 if 参考图5 is not None else 0,
])
element_count = len(element_ids)
max_refs = 4 if 参考视频 is not None else 7
if 模型代号 == "v3-omni" and ref_image_count + element_count > max_refs:
raise RuntimeError(
f"官方约束:{'有' if 参考视频 else '无'}参考视频时,"
f"图片数量({ref_image_count}+ 主体数量({element_count})不能超过 {max_refs}。"
f"当前总计:{ref_image_count + element_count}。"
)
# 起始帧检查:标准图生视频必须接起始帧;v3-omni 可纯文本/主体/参考生成。
略过起始帧 = 起始帧 is None
if 略过起始帧 and 模型代号 != "v3-omni":
raise RuntimeError(
f"{模型代号} 必须提供起始帧。若要使用无起始帧、多参考图、参考视频或主体引用,请切换到 v3-omni。"
)
# ── 约束校验 ──────────────────────────────────────────────────
# 1. 官方约束:MultiShot=true 时不支持首尾帧,故分镜与尾帧互斥
if shots and 尾帧 is not None:
raise RuntimeError("分镜模式与尾帧不能同时使用:开启分镜时官方不支持首尾帧,请将分镜模式设为「禁用」后再接尾帧。")
# 2. 官方约束:ImageList 超过2张图片时不支持设置尾帧(尾帧与参考图互斥)
if 模型代号 == "v3-omni" and 尾帧 is not None and has_ref_images:
raise RuntimeError(
"官方约束:ImageList 超过2张图片时不支持设置尾帧。"
"请选择:(1) 使用首帧+尾帧生视频(移除参考图2-5);"
"或 (2) 使用首帧+参考图生视频(移除尾帧)。"
)
# 3. 视频参数一致性校验
if 模型代号 == "v3-omni" and 参考视频 is not None and 视频参考类型 == "不使用":
raise RuntimeError(
"参数冲突:已接入参考视频,但「视频参考类型」设置为「不使用」。"
"请将视频参考类型改为「特征参考」或「待编辑视频」。"
)
if 模型代号 == "v3-omni" and 参考视频 is None and 视频参考类型 != "不使用":
raise RuntimeError(
f"参数冲突:未接入参考视频,但「视频参考类型」设置为「{视频参考类型}」。"
"请将视频参考类型改为「不使用」,或接入参考视频。"
)
if 模型代号 == "v3-omni" and 宽高比 == "智能":
can_infer_aspect_ratio = 起始帧 is not None or (
参考视频 is not None and 视频参考类型 == "待编辑视频"
)
if not can_infer_aspect_ratio:
raise RuntimeError(
"宽高比不能使用「智能」:未使用首帧参考或待编辑视频时,官方要求必须指定宽高比。"
"请将宽高比改为 16:9、9:16 或 1:1 后重试。"
)
# v2.6 能力约束
_validate_v26(模型代号, 时长, 模式, 生成音频, 尾帧)
# 路由到不同的实现
if 模型代号 == "v3-omni":
save_path = await cls._build_omni(
模型代号, base_url, headers, 起始帧, 提示词, 负向提示词, 时长, 生成音频, 模式, shots, 尾帧, element_ids,
参考图2, 参考图3, 参考图4, 参考图5, 参考视频, 视频参考类型, 保留视频原声, 宽高比,
)
elif 模型代号 in _T_MODEL_MAP:
# 腾讯网关渠道(保留兼容)
save_path = await cls._build_v3t(
模型代号, base_url, headers, 起始帧, 提示词, 负向提示词, 时长, 生成音频, 模式, shots, 尾帧, element_ids,
参考图2, 参考图3, 参考图4, 参考图5, 参考视频, 视频参考类型, 保留视频原声,
)
else:
# 标准 image2video 端点(v3 / v2-6
save_path = await cls._build_standard(
模型代号, base_url, headers, 起始帧, 提示词, 负向提示词, 时长, 生成音频, 模式, shots, 尾帧,
)
if _FOLDER_PATHS_OK:
return io.NodeOutput(InputImpl.VideoFromFile(save_path))
return io.NodeOutput(save_path)
# ── 主体名称 → element_id 解析(查 /mine)──────────────────────────────────
@classmethod
async def _resolve_element_ids(cls, names: list) -> list:
"""把提示词中的【@名称】解析成 element_id 列表。"""
from ..clients.element_client import fetch_name_to_id_map
connector = aiohttp.TCPConnector(ssl=False, force_close=True)
async with aiohttp.ClientSession(connector=connector) as session:
try:
mapping = await run_with_interrupt(
fetch_name_to_id_map(session)
)
except Exception as e:
raise RuntimeError(f"获取主体列表失败:{e}。请检查全局线路与令牌后重试。")
ids, missing = [], []
for name in names:
eid = mapping.get(name)
if eid:
if eid not in ids:
ids.append(eid)
else:
missing.append(name)
if missing:
available = "、".join(mapping.keys()) or "(无)"
raise RuntimeError(
f"提示词引用的主体未找到:{('、'.join(missing))}。"
f"当前账号可用主体:{available}。"
"请确认名称拼写一致,且主体已创建成功(succeed)。"
)
print(f"[K3 图生视频] 主体解析成功:{dict(zip(names, ids))}")
return ids
# ── omni-video 端点(v3-omni)─────────────────────────────────────
@classmethod
async def _build_omni(cls, 模型, base_url, headers, 起始帧, 提示词, 负向提示词, 时长, 生成音频, 模式, shots, 尾帧=None, element_ids=None,
参考图2=None, 参考图3=None, 参考图4=None, 参考图5=None, 参考视频=None, 视频参考类型="不使用", 保留视频原声="否", 宽高比="智能"):
"""多模态视频接口:POST /kling/v1/videos/omni-video。"""
if not shots and not 提示词.strip():
raise RuntimeError(f"{模型} 单段模式错误:提示词不能为空。")
mode_api = _MODE_MAP[模式]
actual_model_name = _STANDARD_MODELS.get(模型, f"kling-{模型}")
body = {
"model_name": actual_model_name,
"mode": mode_api,
"duration": str(时长),
"sound": "on" if 生成音频 == "打开" else "off",
"watermark_info": {"enabled": False},
}
if 宽高比 != "智能":
body["aspect_ratio"] = 宽高比
if not shots:
body["prompt"] = 提示词.strip()
else:
body["multi_shot"] = True
body["shot_type"] = "customize"
body["multi_prompt"] = [
{"index": s["index"], "prompt": s["prompt"], "duration": str(s["duration"])}
for s in shots
]
image_list = []
is_edit_video = 参考视频 is not None and 视频参考类型 == "待编辑视频"
if 起始帧 is not None:
print("[K3 图生视频] 上传起始帧到 OSS...")
image_url = await upload_image(_prepare_image_for_upload(起始帧), base_url=base_url)
image_item = {"image_url": image_url}
if not is_edit_video:
image_item["type"] = "first_frame"
image_list.append(image_item)
if 尾帧 is not None and not shots:
print("[K3 图生视频] 上传尾帧到 OSS...")
tail_url = await upload_image(_prepare_image_for_upload(尾帧), base_url=base_url)
image_item = {"image_url": tail_url}
if not is_edit_video:
image_item["type"] = "end_frame"
image_list.append(image_item)
for idx, ref_img in enumerate([参考图2, 参考图3, 参考图4, 参考图5], start=2):
if ref_img is not None:
print(f"[K3 图生视频] 上传参考图{idx}到 OSS...")
ref_url = await upload_image(_prepare_image_for_upload(ref_img), base_url=base_url)
image_list.append({"image_url": ref_url})
if image_list:
body["image_list"] = image_list
video_list = []
if 参考视频 is not None:
print(f"[K3 图生视频] 参考视频输入: {_describe_reference_video_input(参考视频)}")
video_path = _resolve_reference_video_path(参考视频)
_validate_reference_video(video_path, 模型)
print("[K3 图生视频] 上传参考视频到 OSS...")
from ..utils.r2_uploader import upload_video
video_url = await upload_video(video_path, base_url=base_url)
refer_type_map = {"特征参考": "feature", "待编辑视频": "base"}
video_list.append({
"video_url": video_url,
"refer_type": refer_type_map.get(视频参考类型, "feature"),
"keep_original_sound": "yes" if 保留视频原声 == "是" else "no",
})
if video_list:
body["video_list"] = video_list
if element_ids:
# element_id 必须是 int64 数字类型,不能是字符串
body["element_list"] = [{"element_id": int(e) if isinstance(e, str) else e} for e in element_ids]
print(f"[K3 图生视频] 引用主体 {len(element_ids)} 个:{', '.join(map(str, element_ids))}")
if 负向提示词.strip():
body["negative_prompt"] = 负向提示词.strip()
return await _run_video_task(
base_url, headers, body,
_ENDPOINT_OMNI_CREATE, _ENDPOINT_OMNI_STATUS, f"K3 {模型} 提交: ",
)
# ── v3-t / v2-6-t:腾讯 Kling 网关渠道(PascalCase metadata 透传)──────────────
@classmethod
async def _build_v3t(cls, 模型, base_url, headers, 起始帧, 提示词, 负向提示词, 时长, 生成音频, 模式, shots, 尾帧=None, element_ids=None,
参考图2=None, 参考图3=None, 参考图4=None, 参考图5=None, 参考视频=None, 视频参考类型="不使用", 保留视频原声="否"):
if not shots and not 提示词.strip():
raise RuntimeError(f"{模型} 单段模式错误:提示词不能为空。")
mode_api = _MODE_MAP[模式] # 720p→std / 1080p→pro / 4K→4k
body = {
"model": _T_MODEL_MAP[模型],
# 分镜模式下 Prompt 被官方忽略,但网关仍强制非空,故用首段分镜词占位
"prompt": 提示词.strip() or (shots[0]["prompt"] if shots else " "),
"duration": str(时长),
"mode": mode_api,
}
if 起始帧 is not None:
print("[K3 图生视频] 上传起始帧到 OSS...")
body["image"] = await upload_image(_prepare_image_for_upload(起始帧), base_url=base_url)
metadata = {
"Sound": "on" if 生成音频 == "打开" else "off",
"LogoAdd": 0,
}
if 负向提示词.strip():
metadata["negative_prompt"] = 负向提示词.strip()
if shots:
metadata["MultiShot"] = True
metadata["ShotType"] = "customize"
metadata["MultiPrompt"] = [
{"Index": s["index"], "Prompt": s["prompt"], "Duration": str(s["duration"])}
for s in shots
]
elif 尾帧 is not None:
# 首尾帧:仅非分镜模式可用。腾讯渠道透传原生结构,ImageTail 须为 {"Url": ...} 对象
print("[K3 图生视频] 上传尾帧到 OSS...")
tail_url = await upload_image(_prepare_image_for_upload(尾帧), base_url=base_url)
metadata["ImageTail"] = {"Url": tail_url}
# 主体引用:注入 ElementListPascalCase,最多 3 个,execute 已校验)
if element_ids:
metadata["ElementList"] = [{"ElementId": e} for e in element_ids]
print(f"[K3 图生视频] 引用主体 {len(element_ids)} 个:{', '.join(element_ids)}")
body["metadata"] = metadata
return await _run_video_task(
base_url, headers, body,
_ENDPOINT_V3T_CREATE, _ENDPOINT_V3T_STATUS, f"K3 {模型} 提交: ",
)
# ── 标准 image2video 端点(v3 / v2-6)────────────────────────
@classmethod
async def _build_standard(cls, 模型, base_url, headers, 起始帧, 提示词, 负向提示词, 时长, 生成音频, 模式, shots, 尾帧=None):
"""标准图生视频接口:POST /kling/v1/videos/image2video
支持模型:v3、v2-6
参数通过请求体传递,不再拼接模型名
支持无水印
"""
if not shots and not 提示词.strip():
raise RuntimeError(f"{模型} 单段模式错误:提示词不能为空。")
if 起始帧 is None:
raise RuntimeError(f"{模型} 必须提供起始帧。")
mode_api = _MODE_MAP[模式] # 720p→std / 1080p→pro / 4K→4k
# 获取实际的模型名(v3 → kling-v3
actual_model_name = _STANDARD_MODELS.get(模型, f"kling-{模型}")
print("[K3 图生视频] 上传起始帧到 OSS...")
image_url = await upload_image(_prepare_image_for_upload(起始帧), base_url=base_url)
body = {
"model_name": actual_model_name,
"image": image_url,
"prompt": 提示词.strip() or (shots[0]["prompt"] if shots else " "),
"negative_prompt": 负向提示词.strip(),
"duration": str(时长), # v2-6 和 v3 模型要求 duration 为字符串类型
"mode": mode_api,
"sound": "on" if 生成音频 == "打开" else "off",
"watermark_info": {"enabled": False}, # 默认关闭水印
}
# 分镜模式
if shots:
body["multi_shot"] = True
body["shot_type"] = "customize"
# v2-6 和 v3 模型要求 duration 为字符串类型
body["multi_prompt"] = [
{"index": s["index"], "prompt": s["prompt"], "duration": str(s["duration"])}
for s in shots
]
# 首尾帧
elif 尾帧 is not None:
print("[K3 图生视频] 上传尾帧到 OSS...")
tail_url = await upload_image(_prepare_image_for_upload(尾帧), base_url=base_url)
body["image_tail"] = tail_url # 直接使用 URL 字符串,而非对象
return await _run_video_task(
base_url, headers, body,
_ENDPOINT_IMAGE2VIDEO_CREATE, _ENDPOINT_IMAGE2VIDEO_STATUS, f"K3 {模型} 提交: ",
)
NODE_CLASS_MAPPINGS = {
"K3Video": K3Video,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"K3Video": "K 视频生成",
}