""" 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~15s(v3 支持范围) _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"" 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.6(v2-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/MOV,3-15.5秒,≤200MB,宽高720-2160px,24-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} # 主体引用:注入 ElementList(PascalCase,最多 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 视频生成", }