Files
comfyui_o1key/nodes/K_video_firstlast.py
T
o1keyandClaude Opus 4.6 1a813bfd1d fix: 图生视频节点进度解析兼容百分号格式,K26/K3/Seedance统一走cf-api异步接口,K26新增seed参数
- 修复 K_video_image2video.py 进度值 "10%" 解析报错:rstrip('%') 后 float→int 安全转换
- K26 节点拆分为图生视频/首尾帧两个独立节点,删除旧的合并节点 K_video.py
- K26/K3 视频节点及 Seedance 客户端统一改用 get_async_api_base_url (cf-api.o1key.com)
- K3 节点模式选项从 标准/专家 改为 720p/1080p,与后端一致
- K_video_image2video.py 和 K_video_firstlast.py 新增 ComfyUI 原生 seed 参数

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-05-07 09:48:24 +08:00

252 lines
9.5 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.
"""
K26 图生视频节点
"""
import asyncio
import json
import math
import os
import tempfile
import aiohttp
from ..utils.config import get_api_key_or_raise, get_async_api_base_url
from ..utils.image_utils import tensor_to_pil, encode_image_to_base64
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 KVideoFirstLast:
"""K26 图生视频节点(首尾帧)"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"起始帧": ("IMAGE",),
"提示词": ("STRING", {"multiline": True, "default": ""}),
"模式": (["1080p"],),
"时长": ([5, 10],),
"生成音频": (["关闭", "打开"], {"default": "关闭"}),
"seed": ("INT", {
"default": 0, "min": 0, "max": 2147483647,
"tooltip": "seed 仅控制节点是否重新运行,结果本身不可复现。",
}),
},
"optional": {
"尾帧": ("IMAGE",),
},
}
RETURN_TYPES = ("VIDEO",)
RETURN_NAMES = ("视频",)
FUNCTION = "generate"
CATEGORY = "comfyui_o1key/KVideo"
async def generate(self, 起始帧, 提示词, 模式, 时长, 生成音频="关闭", 尾帧=None, seed=0):
api_key = get_api_key_or_raise()
base_url = get_async_api_base_url()
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
# ── 动态拼接模型名 ────────────────────────────────────────────
mode_api = "pro" # 1080p 映射为 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
if 尾帧 is not None:
body["metadata"] = {"image_tail": _image_to_base64(尾帧, scale)}
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}"
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"K26 提交失败 ({resp.status}): {msg}")
create_resp = json.loads(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}{_ENDPOINT_STATUS.format(task_id=task_id)}"
interval = _POLL_INIT
video_url = None
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}")
sr = json.loads(text)
data = sr.get("data", sr)
status = (data.get("status") or sr.get("status") or "").lower()
pct_raw = data.get("progress", 0)
try:
pct = int(str(pct_raw).rstrip("%").strip())
except (ValueError, AttributeError):
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 = {
"KVideoFirstLast": KVideoFirstLast,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"KVideoFirstLast": "K26 图生视频(首尾帧)",
}