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]>
This commit is contained in:
+5
-3
@@ -12,7 +12,7 @@ Comfyui_o1key - ComfyUI 自定义节点集合
|
|||||||
|
|
||||||
import ssl
|
import ssl
|
||||||
|
|
||||||
from .nodes import NanoBananaPro, BatchNanoBananaPro, GoogleGemini, LoadFile, ImageStitchPro, SaveCleanImage, BatchCleanMetadata, VideoPreview, GoogleVeo, FluxImageEdit, UniversalLLMChat, KlingVideo, KlingFirstLastFrame, KlingMotionControlTest, AspectRatioPreset, MultiResPreview, BatchImagesO1key, Seedance, SeedanceMultiModal, StreamPreview, DoubaoImage, O1keyGPTImage, KVideo
|
from .nodes import NanoBananaPro, BatchNanoBananaPro, GoogleGemini, LoadFile, ImageStitchPro, SaveCleanImage, BatchCleanMetadata, VideoPreview, GoogleVeo, FluxImageEdit, UniversalLLMChat, KlingVideo, KlingFirstLastFrame, KlingMotionControlTest, AspectRatioPreset, MultiResPreview, BatchImagesO1key, Seedance, SeedanceMultiModal, StreamPreview, DoubaoImage, O1keyGPTImage, KVideoFirstLast, KVideoImage2Video
|
||||||
from .nodes import K3Video, K3VideoFirstLast, K3MotionControl, K3MotionVideoCheck, NanoBananaV2, NanoBananaV2Batch
|
from .nodes import K3Video, K3VideoFirstLast, K3MotionControl, K3MotionVideoCheck, NanoBananaV2, NanoBananaV2Batch
|
||||||
|
|
||||||
# 报错弹框友好文案(不修改原节点代码,仅在外层统一处理)
|
# 报错弹框友好文案(不修改原节点代码,仅在外层统一处理)
|
||||||
@@ -74,7 +74,8 @@ NODE_CLASS_MAPPINGS = {
|
|||||||
"StreamPreview": StreamPreview,
|
"StreamPreview": StreamPreview,
|
||||||
"DoubaoImage": DoubaoImage,
|
"DoubaoImage": DoubaoImage,
|
||||||
"O1keyGPTImage": O1keyGPTImage,
|
"O1keyGPTImage": O1keyGPTImage,
|
||||||
"KVideo": KVideo,
|
"KVideoFirstLast": KVideoFirstLast,
|
||||||
|
"KVideoImage2Video": KVideoImage2Video,
|
||||||
"K3Video": K3Video,
|
"K3Video": K3Video,
|
||||||
"K3VideoFirstLast": K3VideoFirstLast,
|
"K3VideoFirstLast": K3VideoFirstLast,
|
||||||
"K3MotionControl": K3MotionControl,
|
"K3MotionControl": K3MotionControl,
|
||||||
@@ -106,7 +107,8 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
|||||||
"StreamPreview": "流式文本预览",
|
"StreamPreview": "流式文本预览",
|
||||||
"DoubaoImage": "豆包生图",
|
"DoubaoImage": "豆包生图",
|
||||||
"O1keyGPTImage": "o1key GPT Image",
|
"O1keyGPTImage": "o1key GPT Image",
|
||||||
"KVideo": "K26 图生视频",
|
"KVideoFirstLast": "K26 图生视频(首尾帧)",
|
||||||
|
"KVideoImage2Video": "K26 图生视频",
|
||||||
"K3Video": "K3 图生视频 自研",
|
"K3Video": "K3 图生视频 自研",
|
||||||
"K3VideoFirstLast": "首尾帧 K3 自研",
|
"K3VideoFirstLast": "首尾帧 K3 自研",
|
||||||
"K3MotionControl": "动作控制 K3 自研",
|
"K3MotionControl": "动作控制 K3 自研",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from typing import Any, Callable, Dict, Optional
|
|||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
from ..utils.config import get_api_key_or_raise, get_api_base_url
|
from ..utils.config import get_api_key_or_raise, get_async_api_base_url
|
||||||
|
|
||||||
|
|
||||||
class SeedanceClient:
|
class SeedanceClient:
|
||||||
@@ -30,7 +30,7 @@ class SeedanceClient:
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.api_key = get_api_key_or_raise()
|
self.api_key = get_api_key_or_raise()
|
||||||
self.base_url = get_api_base_url()
|
self.base_url = get_async_api_base_url()
|
||||||
|
|
||||||
def _headers(self) -> Dict[str, str]:
|
def _headers(self) -> Dict[str, str]:
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import tempfile
|
|||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
from ..utils.config import get_api_key_or_raise, get_api_base_url
|
from ..utils.config import get_api_key_or_raise, get_async_api_base_url
|
||||||
from ..utils.r2_uploader import upload_video, upload_image
|
from ..utils.r2_uploader import upload_video, upload_image
|
||||||
from ..utils.image_utils import tensor_to_pil
|
from ..utils.image_utils import tensor_to_pil
|
||||||
|
|
||||||
@@ -107,7 +107,7 @@ class K3MotionControl:
|
|||||||
"参考视频": ("VIDEO",),
|
"参考视频": ("VIDEO",),
|
||||||
"提示词": ("STRING", {"multiline": True, "default": ""}),
|
"提示词": ("STRING", {"multiline": True, "default": ""}),
|
||||||
"模型": (["v3", "v2-6"], {"default": "v3"}),
|
"模型": (["v3", "v2-6"], {"default": "v3"}),
|
||||||
"模式": (["标准", "专家"], {"default": "专家"}),
|
"模式": (["720p", "1080p"], {"default": "1080p"}),
|
||||||
"时长": ([5, 10, 15, 20, 25, 30], {"default": 5}),
|
"时长": ([5, 10, 15, 20, 25, 30], {"default": 5}),
|
||||||
"角色朝向": (["图片", "视频"], {"default": "图片"}),
|
"角色朝向": (["图片", "视频"], {"default": "图片"}),
|
||||||
"保留原声": (["打开", "关闭"], {"default": "打开"}),
|
"保留原声": (["打开", "关闭"], {"default": "打开"}),
|
||||||
@@ -125,14 +125,14 @@ class K3MotionControl:
|
|||||||
|
|
||||||
async def generate(self, 参考图片, 参考视频, 提示词, 保留原声, 角色朝向, 模式, 模型, 时长, seed, **kwargs):
|
async def generate(self, 参考图片, 参考视频, 提示词, 保留原声, 角色朝向, 模式, 模型, 时长, seed, **kwargs):
|
||||||
api_key = get_api_key_or_raise()
|
api_key = get_api_key_or_raise()
|
||||||
base_url = get_api_base_url()
|
base_url = get_async_api_base_url()
|
||||||
headers = {
|
headers = {
|
||||||
"Authorization": f"Bearer {api_key}",
|
"Authorization": f"Bearer {api_key}",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── 参数映射 ──────────────────────────────────────────────────
|
# ── 参数映射 ──────────────────────────────────────────────────
|
||||||
mode_api = "std" if 模式 == "标准" else "pro"
|
mode_api = "std" if 模式 == "720p" else "pro"
|
||||||
model_name = f"kling-{模型}-motion-{mode_api}-{时长}s"
|
model_name = f"kling-{模型}-motion-{mode_api}-{时长}s"
|
||||||
character_orientation = "image" if 角色朝向 == "图片" else "video"
|
character_orientation = "image" if 角色朝向 == "图片" else "video"
|
||||||
keep_sound = "yes" if 保留原声 == "打开" else "no"
|
keep_sound = "yes" if 保留原声 == "打开" else "no"
|
||||||
|
|||||||
+5
-5
@@ -11,7 +11,7 @@ import tempfile
|
|||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
from ..utils.config import get_api_key_or_raise, get_api_base_url
|
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
|
from ..utils.image_utils import tensor_to_pil, encode_image_to_base64
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -25,8 +25,8 @@ except Exception:
|
|||||||
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
_MODEL_BASE = "kling-v3" # 动态拼接为 kling-v3-{模式}-{时长}s-{voice}
|
_MODEL_BASE = "kling-v3" # 动态拼接为 kling-v3-{模式}-{时长}s-{voice}
|
||||||
_MODES = ["标准", "专家", "4K"]
|
_MODES = ["720p", "1080p", "4K"]
|
||||||
_MODE_MAP = {"标准": "std", "专家": "pro", "4K": "4k"}
|
_MODE_MAP = {"720p": "std", "1080p": "pro", "4K": "4k"}
|
||||||
|
|
||||||
_MULTI_SHOT_OPTIONS = [
|
_MULTI_SHOT_OPTIONS = [
|
||||||
"禁用",
|
"禁用",
|
||||||
@@ -112,7 +112,7 @@ class K3Video:
|
|||||||
"负向提示词": ("STRING", {"multiline": True, "default": ""}),
|
"负向提示词": ("STRING", {"multiline": True, "default": ""}),
|
||||||
"时长": ([5, 10, 15], {"default": 5}),
|
"时长": ([5, 10, 15], {"default": 5}),
|
||||||
"生成音频": (["关闭", "打开"], {"default": "关闭"}),
|
"生成音频": (["关闭", "打开"], {"default": "关闭"}),
|
||||||
"模式": (_MODES, {"default": "标准"}),
|
"模式": (_MODES, {"default": "720p"}),
|
||||||
"seed": ("INT", {
|
"seed": ("INT", {
|
||||||
"default": 0, "min": 0, "max": 2147483647,
|
"default": 0, "min": 0, "max": 2147483647,
|
||||||
"tooltip": "seed 仅控制节点是否重新运行,结果本身不可复现。",
|
"tooltip": "seed 仅控制节点是否重新运行,结果本身不可复现。",
|
||||||
@@ -139,7 +139,7 @@ class K3Video:
|
|||||||
|
|
||||||
async def generate(self, 多镜头, 起始帧, 提示词, 负向提示词, 时长, 生成音频, 模式, seed, **kwargs):
|
async def generate(self, 多镜头, 起始帧, 提示词, 负向提示词, 时长, 生成音频, 模式, seed, **kwargs):
|
||||||
api_key = get_api_key_or_raise()
|
api_key = get_api_key_or_raise()
|
||||||
base_url = get_api_base_url()
|
base_url = get_async_api_base_url()
|
||||||
headers = {
|
headers = {
|
||||||
"Authorization": f"Bearer {api_key}",
|
"Authorization": f"Bearer {api_key}",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import tempfile
|
|||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
from ..utils.config import get_api_key_or_raise, get_api_base_url
|
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
|
from ..utils.image_utils import tensor_to_pil, encode_image_to_base64
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -24,8 +24,8 @@ except Exception:
|
|||||||
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
_MODEL_BASE = "kling-v3"
|
_MODEL_BASE = "kling-v3"
|
||||||
_MODES = ["标准", "专家", "4K"]
|
_MODES = ["720p", "1080p", "4K"]
|
||||||
_MODE_MAP = {"标准": "std", "专家": "pro", "4K": "4k"}
|
_MODE_MAP = {"720p": "std", "1080p": "pro", "4K": "4k"}
|
||||||
|
|
||||||
_ENDPOINT_CREATE = "/v1/video/generations"
|
_ENDPOINT_CREATE = "/v1/video/generations"
|
||||||
_ENDPOINT_STATUS = "/v1/video/generations/{task_id}"
|
_ENDPOINT_STATUS = "/v1/video/generations/{task_id}"
|
||||||
@@ -93,7 +93,7 @@ class K3VideoFirstLast:
|
|||||||
"负向提示词": ("STRING", {"multiline": True, "default": ""}),
|
"负向提示词": ("STRING", {"multiline": True, "default": ""}),
|
||||||
"时长": ([5, 10, 15], {"default": 5}),
|
"时长": ([5, 10, 15], {"default": 5}),
|
||||||
"生成音频": (["关闭", "打开"], {"default": "关闭"}),
|
"生成音频": (["关闭", "打开"], {"default": "关闭"}),
|
||||||
"模式": (_MODES, {"default": "标准"}),
|
"模式": (_MODES, {"default": "720p"}),
|
||||||
"seed": ("INT", {
|
"seed": ("INT", {
|
||||||
"default": 0, "min": 0, "max": 2147483647,
|
"default": 0, "min": 0, "max": 2147483647,
|
||||||
"tooltip": "seed 仅控制节点是否重新运行,结果本身不可复现。",
|
"tooltip": "seed 仅控制节点是否重新运行,结果本身不可复现。",
|
||||||
@@ -111,7 +111,7 @@ class K3VideoFirstLast:
|
|||||||
|
|
||||||
async def generate(self, 起始帧, 提示词, 负向提示词, 时长, 生成音频, 模式, seed, 尾帧=None):
|
async def generate(self, 起始帧, 提示词, 负向提示词, 时长, 生成音频, 模式, seed, 尾帧=None):
|
||||||
api_key = get_api_key_or_raise()
|
api_key = get_api_key_or_raise()
|
||||||
base_url = get_api_base_url()
|
base_url = get_async_api_base_url()
|
||||||
headers = {
|
headers = {
|
||||||
"Authorization": f"Bearer {api_key}",
|
"Authorization": f"Bearer {api_key}",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ K26 图生视频节点
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
from ..utils.config import get_api_key_or_raise, get_api_base_url
|
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
|
from ..utils.image_utils import tensor_to_pil, encode_image_to_base64
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -30,13 +31,20 @@ _POLL_INIT = 3
|
|||||||
_POLL_MAX = 15
|
_POLL_MAX = 15
|
||||||
|
|
||||||
|
|
||||||
def _image_to_base64(tensor) -> str:
|
def _image_to_base64(tensor, scale=1.0) -> str:
|
||||||
|
from PIL import Image
|
||||||
pil = tensor_to_pil(tensor)
|
pil = tensor_to_pil(tensor)
|
||||||
return encode_image_to_base64(pil[0], format="PNG")
|
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 KVideo:
|
class KVideoFirstLast:
|
||||||
"""K26 图生视频节点"""
|
"""K26 图生视频节点(首尾帧)"""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def INPUT_TYPES(cls):
|
def INPUT_TYPES(cls):
|
||||||
@@ -44,9 +52,13 @@ class KVideo:
|
|||||||
"required": {
|
"required": {
|
||||||
"起始帧": ("IMAGE",),
|
"起始帧": ("IMAGE",),
|
||||||
"提示词": ("STRING", {"multiline": True, "default": ""}),
|
"提示词": ("STRING", {"multiline": True, "default": ""}),
|
||||||
"模式": (["pro"],),
|
"模式": (["1080p"],),
|
||||||
"时长": ([5, 10],),
|
"时长": ([5, 10],),
|
||||||
"生成音频": (["关闭", "打开"], {"default": "关闭"}),
|
"生成音频": (["关闭", "打开"], {"default": "关闭"}),
|
||||||
|
"seed": ("INT", {
|
||||||
|
"default": 0, "min": 0, "max": 2147483647,
|
||||||
|
"tooltip": "seed 仅控制节点是否重新运行,结果本身不可复现。",
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
"optional": {
|
"optional": {
|
||||||
"尾帧": ("IMAGE",),
|
"尾帧": ("IMAGE",),
|
||||||
@@ -58,30 +70,56 @@ class KVideo:
|
|||||||
FUNCTION = "generate"
|
FUNCTION = "generate"
|
||||||
CATEGORY = "comfyui_o1key/KVideo"
|
CATEGORY = "comfyui_o1key/KVideo"
|
||||||
|
|
||||||
async def generate(self, 起始帧, 提示词, 模式, 时长, 生成音频="关闭", 尾帧=None):
|
async def generate(self, 起始帧, 提示词, 模式, 时长, 生成音频="关闭", 尾帧=None, seed=0):
|
||||||
api_key = get_api_key_or_raise()
|
api_key = get_api_key_or_raise()
|
||||||
base_url = get_api_base_url()
|
base_url = get_async_api_base_url()
|
||||||
headers = {
|
headers = {
|
||||||
"Authorization": f"Bearer {api_key}",
|
"Authorization": f"Bearer {api_key}",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── 动态拼接模型名 ────────────────────────────────────────────
|
# ── 动态拼接模型名 ────────────────────────────────────────────
|
||||||
|
mode_api = "pro" # 1080p 映射为 pro
|
||||||
voice = "voice" if 生成音频 == "打开" else "novoice"
|
voice = "voice" if 生成音频 == "打开" else "novoice"
|
||||||
model_name = f"{_MODEL_BASE}-{模式}-{时长}s-{voice}"
|
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 = {
|
body = {
|
||||||
"model": model_name,
|
"model": model_name,
|
||||||
"prompt": 提示词.strip(),
|
"prompt": 提示词.strip(),
|
||||||
"image": _image_to_base64(起始帧),
|
"image": _image_to_base64(起始帧, scale),
|
||||||
"mode": 模式,
|
"mode": mode_api,
|
||||||
"duration": 时长,
|
"duration": 时长,
|
||||||
}
|
}
|
||||||
if 生成音频 == "打开":
|
if 生成音频 == "打开":
|
||||||
body["generate_audio"] = True
|
body["generate_audio"] = True
|
||||||
if 尾帧 is not None:
|
if 尾帧 is not None:
|
||||||
body["metadata"] = {"image_tail": _image_to_base64(尾帧)}
|
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:
|
try:
|
||||||
@@ -205,9 +243,9 @@ class KVideo:
|
|||||||
|
|
||||||
|
|
||||||
NODE_CLASS_MAPPINGS = {
|
NODE_CLASS_MAPPINGS = {
|
||||||
"KVideo": KVideo,
|
"KVideoFirstLast": KVideoFirstLast,
|
||||||
}
|
}
|
||||||
|
|
||||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||||
"KVideo": "K26 图生视频",
|
"KVideoFirstLast": "K26 图生视频(首尾帧)",
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
"""
|
||||||
|
K26 图生视频节点
|
||||||
|
支持 720p 和 1080p 模式
|
||||||
|
"""
|
||||||
|
|
||||||
|
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 KVideoImage2Video:
|
||||||
|
"""K26 图生视频节点"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"起始帧": ("IMAGE",),
|
||||||
|
"提示词": ("STRING", {"multiline": True, "default": ""}),
|
||||||
|
"模式": (["720p", "1080p"], {"default": "720p"}),
|
||||||
|
"时长": ([5, 10], {"default": 5}),
|
||||||
|
"生成音频": (["关闭", "打开"], {"default": "关闭"}),
|
||||||
|
"seed": ("INT", {
|
||||||
|
"default": 0, "min": 0, "max": 2147483647,
|
||||||
|
"tooltip": "seed 仅控制节点是否重新运行,结果本身不可复现。",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
RETURN_TYPES = ("VIDEO",)
|
||||||
|
RETURN_NAMES = ("视频",)
|
||||||
|
FUNCTION = "generate"
|
||||||
|
CATEGORY = "comfyui_o1key/KVideo"
|
||||||
|
|
||||||
|
async def generate(self, 起始帧, 提示词, 模式, 时长, 生成音频="关闭", 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 = "std" if 模式 == "720p" else "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
|
||||||
|
|
||||||
|
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:
|
||||||
|
if resp.status != 200:
|
||||||
|
err_text = await resp.text()
|
||||||
|
raise RuntimeError(f"提交失败 ({resp.status}): {err_text}")
|
||||||
|
sr = await resp.json()
|
||||||
|
|
||||||
|
task_id = sr.get("task_id") or sr.get("id")
|
||||||
|
if not task_id:
|
||||||
|
raise RuntimeError(f"API 未返回 task_id,响应:{sr}")
|
||||||
|
|
||||||
|
_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:
|
||||||
|
await asyncio.sleep(interval)
|
||||||
|
|
||||||
|
async with session.get(status_url, headers=headers) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
err_text = await resp.text()
|
||||||
|
raise RuntimeError(f"查询失败 ({resp.status}): {err_text}")
|
||||||
|
sr = await resp.json()
|
||||||
|
|
||||||
|
data = sr.get("data", {}) or {}
|
||||||
|
status = (sr.get("status") or data.get("status") or "").lower()
|
||||||
|
|
||||||
|
pct_raw = str(data.get("progress", 0)).strip().rstrip('%')
|
||||||
|
try:
|
||||||
|
pct = max(0, min(100, int(float(pct_raw))))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
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 = {
|
||||||
|
"KVideoImage2Video": KVideoImage2Video,
|
||||||
|
}
|
||||||
|
|
||||||
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||||
|
"KVideoImage2Video": "K26 图生视频",
|
||||||
|
}
|
||||||
+3
-2
@@ -21,9 +21,10 @@ from .seedance_video import Seedance, SeedanceMultiModal
|
|||||||
from .nano_banana_v2 import NanoBananaV2, NanoBananaV2Batch, AsyncImageGenerator, BatchAsyncImageGenerator
|
from .nano_banana_v2 import NanoBananaV2, NanoBananaV2Batch, AsyncImageGenerator, BatchAsyncImageGenerator
|
||||||
from .doubao_image import DoubaoImage
|
from .doubao_image import DoubaoImage
|
||||||
from .gpt_image import O1keyGPTImage
|
from .gpt_image import O1keyGPTImage
|
||||||
from .K_video import KVideo
|
from .K_video_firstlast import KVideoFirstLast
|
||||||
|
from .K_video_image2video import KVideoImage2Video
|
||||||
from .K3_video import K3Video
|
from .K3_video import K3Video
|
||||||
from .K3_video_firstlast import K3VideoFirstLast
|
from .K3_video_firstlast import K3VideoFirstLast
|
||||||
from .K3_motion_control import K3MotionControl, K3MotionVideoCheck
|
from .K3_motion_control import K3MotionControl, K3MotionVideoCheck
|
||||||
|
|
||||||
__all__ = ['NanoBananaV2', 'NanoBananaV2Batch', 'NanoBananaPro', 'BatchNanoBananaPro', 'GoogleGemini', 'LoadFile', 'ImageStitchPro', 'SaveCleanImage', 'BatchCleanMetadata', 'VideoPreview', 'KlingVideo', 'KlingFirstLastFrame', 'KlingMotionControlTest', 'AspectRatioPreset', 'GoogleVeo', 'FluxImageEdit', 'UniversalLLMChat', 'MultiResPreview', 'BatchImagesO1key', 'Seedance', 'SeedanceMultiModal', 'StreamPreview', 'DoubaoImage', 'O1keyGPTImage', 'KVideo', 'K3Video', 'K3VideoFirstLast', 'K3MotionControl', 'K3MotionVideoCheck', 'AsyncImageGenerator', 'BatchAsyncImageGenerator']
|
__all__ = ['NanoBananaV2', 'NanoBananaV2Batch', 'NanoBananaPro', 'BatchNanoBananaPro', 'GoogleGemini', 'LoadFile', 'ImageStitchPro', 'SaveCleanImage', 'BatchCleanMetadata', 'VideoPreview', 'KlingVideo', 'KlingFirstLastFrame', 'KlingMotionControlTest', 'AspectRatioPreset', 'GoogleVeo', 'FluxImageEdit', 'UniversalLLMChat', 'MultiResPreview', 'BatchImagesO1key', 'Seedance', 'SeedanceMultiModal', 'StreamPreview', 'DoubaoImage', 'O1keyGPTImage', 'KVideoFirstLast', 'KVideoImage2Video', 'K3Video', 'K3VideoFirstLast', 'K3MotionControl', 'K3MotionVideoCheck', 'AsyncImageGenerator', 'BatchAsyncImageGenerator']
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ DEBUG_LOG_ENABLED = False
|
|||||||
REQUEST_LOG_ENABLED = False
|
REQUEST_LOG_ENABLED = False
|
||||||
|
|
||||||
_POLL_INTERVAL = 2 # 轮询间隔(秒)
|
_POLL_INTERVAL = 2 # 轮询间隔(秒)
|
||||||
|
_INTERRUPT_CHECK_INTERVAL = 0.1 # 取消检查间隔(秒)
|
||||||
_MAX_WAIT_TIME = 900 # 单任务最大等待时间(秒)
|
_MAX_WAIT_TIME = 900 # 单任务最大等待时间(秒)
|
||||||
|
|
||||||
|
|
||||||
@@ -374,7 +375,11 @@ class NanoBananaV2:
|
|||||||
friendly_msg = self._friendly_error(error_msg)
|
friendly_msg = self._friendly_error(error_msg)
|
||||||
raise RuntimeError(f"任务失败: {friendly_msg}")
|
raise RuntimeError(f"任务失败: {friendly_msg}")
|
||||||
elif status in ("SUBMITTED", "IN_PROGRESS"):
|
elif status in ("SUBMITTED", "IN_PROGRESS"):
|
||||||
await asyncio.sleep(_POLL_INTERVAL)
|
# 分段 sleep,每 0.1 秒检查一次取消信号
|
||||||
|
sleep_iterations = int(_POLL_INTERVAL / _INTERRUPT_CHECK_INTERVAL)
|
||||||
|
for _ in range(sleep_iterations):
|
||||||
|
self._check_interrupt()
|
||||||
|
await asyncio.sleep(_INTERRUPT_CHECK_INTERVAL)
|
||||||
else:
|
else:
|
||||||
raise RuntimeError(f"未知任务状态: {status}")
|
raise RuntimeError(f"未知任务状态: {status}")
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ async def _presign(filename: str, content_type: str) -> tuple:
|
|||||||
api_key = get_api_key_or_raise()
|
api_key = get_api_key_or_raise()
|
||||||
base_url = get_api_base_url()
|
base_url = get_api_base_url()
|
||||||
|
|
||||||
async with aiohttp.ClientSession() as session:
|
connector = aiohttp.TCPConnector(ssl=False)
|
||||||
|
async with aiohttp.ClientSession(connector=connector) as session:
|
||||||
async with session.post(
|
async with session.post(
|
||||||
f"{base_url}/v1/storage/presign",
|
f"{base_url}/v1/storage/presign",
|
||||||
headers={"Authorization": f"Bearer {api_key}"},
|
headers={"Authorization": f"Bearer {api_key}"},
|
||||||
@@ -35,7 +36,8 @@ async def _presign(filename: str, content_type: str) -> tuple:
|
|||||||
|
|
||||||
async def _put_upload(upload_url: str, data: bytes, content_type: str):
|
async def _put_upload(upload_url: str, data: bytes, content_type: str):
|
||||||
"""用预签名 URL 直传文件到 R2(不带 Authorization)"""
|
"""用预签名 URL 直传文件到 R2(不带 Authorization)"""
|
||||||
async with aiohttp.ClientSession() as session:
|
connector = aiohttp.TCPConnector(ssl=False)
|
||||||
|
async with aiohttp.ClientSession(connector=connector) as session:
|
||||||
async with session.put(
|
async with session.put(
|
||||||
upload_url,
|
upload_url,
|
||||||
data=data,
|
data=data,
|
||||||
|
|||||||
Reference in New Issue
Block a user