Files

4260 lines
153 KiB
Python
Raw Permalink 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.
from .api import AdvancedLipSync, Avatar, Client, FaceIdentify, ImageGenerator, ImageExpander, Image2Video, \
Video2Audio, Text2Audio, Text2Video, CameraControl, CameraControlConfig, KolorsVurtualTryOn, VideoExtend, \
LipSyncInput, EffectInput, Effects, MultiImages2Video, MultiModelVideoEdit, AdvancedCustomElements, \
MotionControl, CustomVoiceCreate, CustomVoiceQuery, TTS, CLOTHING_DUPE_VOICE_IDS, submit_clothing_dupe
from .api.capabilities import CUSTOM_VOICE_LIST_TYPE, DEFAULT_IMAGE_RESOLUTIONS, DEFAULT_MODES, \
DEFAULT_VIDEO_ASPECT_RATIOS, DEFAULT_VIDEO_DURATIONS, ELEMENT_LIST_TYPE, ELEMENT_TYPE, \
EXTENDED_IMAGE_ASPECT_RATIOS, IMAGE_GENERATION_MODELS, IMAGE_TO_VIDEO_MODELS, LIPSYNC_INPUT_TYPE, \
MULTI_IMAGE_TO_VIDEO_MODELS, NODE_CATEGORY, NODE_PREFIX, SHOT_TYPES, SOUND_OPTIONS, TEXT_TO_VIDEO_MODELS, \
VIDEO_GENERATION_MODES, get_image_capability, get_video_capability, validate_image_generation_request, \
validate_video_generation_request
from .api.exceptions import KLingAPIError
from .api.providers import (
OFFICIAL_PROVIDER,
TENCENT_VOD_PROVIDER,
VAPEUR_PROVIDER,
VIDEO_PROVIDER_OPTIONS,
AdvancedElementCreateRequest,
CustomVoiceCreateRequest,
Image2VideoRequest,
MotionControlRequest,
MultiImagesToVideoRequest,
MultiModalVideoEditRequest,
OfficialVideoConfig,
OfficialVideoProvider,
TencentVodVideoConfig,
TencentVodVideoProvider,
VapeurVideoConfig,
VapeurVideoProvider,
Text2VideoRequest,
)
import base64
import configparser
from contextlib import contextmanager
import email.utils
import hashlib
import hmac
import io
import json
import mimetypes
import os
import re
import shutil
import subprocess
import tempfile
import numpy
import PIL
import requests
import torch
from collections.abc import Iterable
import folder_paths
from comfy_extras.nodes_audio import LoadAudio
import time
import urllib.parse
import uuid
import wave
from pathlib import Path
ROOT_DIR = Path(__file__).resolve().parents[1]
CONFIG_JSON_PATH = ROOT_DIR / "config.local.json"
LEGACY_CONFIG_PATH = ROOT_DIR / "config.ini"
LEGACY_CONFIG_SECTION = "API"
DEFAULT_FILENAME_PREFIX = NODE_PREFIX
DEFAULT_VIDEO_FILENAME_PREFIX = "video/Kling_%year%%month%%day%_%hour%%minute%%second%"
DEFAULT_REQUEST_TIMEOUT = 30
DEFAULT_POLL_INTERVAL = 1.0
DEFAULT_UPLOAD_TIMEOUT = 120
DEFAULT_AREA_OPTIONS = ["global", "china"]
CAMERA_CONTROL_TYPES = ["None", "simple", "down_back", "forward_up", "right_turn_forward", "left_turn_forward"]
CAMERA_CONTROL_CONFIGS = ["horizontal", "vertical", "pan", "tilt", "roll", "zoom"]
MOTION_CONTROL_MODELS = ["kling-v2-6", "kling-v3"]
MOTION_CONTROL_DURATIONS = ["auto", "3", "5", "10", "15", "30"]
TMPFILES_UPLOAD_API_URL = "https://tmpfiles.org/api/v1/upload"
CATBOX_UPLOAD_API_URL = "https://catbox.moe/user/api.php"
TMPFILES_MAX_SIZE_BYTES = 100 * 1024 * 1024
TMPFILES_UPLOAD_RETRY_COUNT = 3
TMPFILES_UPLOAD_RETRY_DELAY = 1.0
LOCAL_MEDIA_UPLOAD_SUBFOLDER = "kling_uploads"
DEFAULT_REFERENCE_VIDEO_FPS = 24.0
CLOTHING_DUPE_ASPECT_RATIOS = ["auto", "9:16", "2:3", "3:4", "1:1", "4:3", "3:2", "16:9", "21:9"]
CLOTHING_DUPE_VOICE_ID_BY_LABEL = {
"无": None,
"活力带货女": "energetic_sales_girl",
"温柔带货女": "gentle_sales_woman",
"清脆带货女": "crisp_sales_girl",
"知性带货女": "mature_sales_woman",
"平静带货女": "calm_sales_woman",
"柔和带货女": "soft_sales_woman",
"活力带货男": "energetic_sales_boy",
"热情带货男": "enthusiastic_sales_man",
"沉稳带货男": "steady_sales_man",
"平静带货男": "calm_sales_man",
}
CLOTHING_DUPE_VOICE_OPTIONS = list(CLOTHING_DUPE_VOICE_ID_BY_LABEL)
LIPSYNC_AUDIO_TYPES = {
"阳光少年": "genshin_vindi2",
"懂事小弟": "zhinen_xuesheng",
"运动少年": "tiyuxi_xuedi",
"青春少女": "ai_shatang",
"温柔小妹": "genshin_klee2",
"元气少女": "guanxiaofang-v2",
"阳光男生": "ai_kaiya",
"幽默小哥": "tiexin_nanyou",
"文艺小哥": "ai_chenjiahao_712",
"甜美邻家": "girlfriend_1_speech02",
"温柔姐姐": "chat1_female_new-3",
"职场女青": "girlfriend_2_speech02",
"活泼男童": "cartoon-boy-07",
"俏皮女童": "cartoon-girl-01",
"稳重老爸": "ai_huangyaoshi_712",
"温柔妈妈": "you_pingjing",
"严肃上司": "ai_laoguowang_712",
"优雅贵妇": "chengshu_jiejie",
"慈祥爷爷": "zhuxi_speech02",
"唠叨爷爷": "uk_oldman3",
"唠叨奶奶": "laopopo_speech02",
"和蔼奶奶": "heainainai_speech02",
"东北老铁": "dongbeilaotie_speech02",
"重庆小伙": "chongqingxiaohuo_speech02",
"四川妹子": "chuanmeizi_speech02",
"潮汕大叔": "chaoshandashu_speech02",
"台湾男生": "ai_taiwan_man2_speech02",
"西安掌柜": "xianzhanggui_speech02",
"天津姐姐": "tianjinjiejie_speech02",
"新闻播报男": "diyinnansang_DB_CN_M_04-v2",
"译制片男": "yizhipiannan-v1",
"撒娇女友": "tianmeixuemei-v1",
"刀片烟嗓": "daopianyansang-v1",
"乖巧正太": "mengwa-v1",
"Sunny": "genshin_vindi2",
"Sage": "zhinen_xuesheng",
"Ace": "AOT",
"Blossom": "ai_shatang",
"Peppy": "genshin_klee2",
"Dove": "genshin_kirara",
"Shine": "ai_kaiya",
"Anchor": "oversea_male1",
"Lyric": "ai_chenjiahao_712",
"Melody": "girlfriend_4_speech02",
"Tender": "chat1_female_new-3",
"Siren": "chat_0407_5-1",
"Zippy": "cartoon-boy-07",
"Bud": "uk_boy1",
"Sprite": "cartoon-girl-01",
"Candy": "PeppaPig_platform",
"Beacon": "ai_huangzhong_712",
"Rock": "ai_huangyaoshi_712",
"Titan": "ai_laoguowang_712",
"Grace": "chengshu_jiejie",
"Helen": "you_pingjing",
"Lore": "calm_story1",
"Crag": "uk_man2",
"Prattle": "laopopo_speech02",
"Hearth": "heainainai_speech02",
"The Reader": "reader_en_m-v1",
"Commercial Lady": "commercial_lady_en_f-v1",
}
def _build_voice_preset_items():
labels_by_id = {}
for label, voice_id in LIPSYNC_AUDIO_TYPES.items():
labels_by_id.setdefault(voice_id, []).append(label)
items = [("None", "")]
for voice_id, labels in labels_by_id.items():
preferred_label = next((label for label in labels if label.isascii()), labels[0])
items.append((f"{preferred_label} | {voice_id}", voice_id))
return items
VOICE_PRESET_ITEMS = _build_voice_preset_items()
VOICE_PRESET_OPTIONS = [label for label, _ in VOICE_PRESET_ITEMS]
VOICE_PRESET_VALUE_BY_LABEL = {label: voice_id for label, voice_id in VOICE_PRESET_ITEMS}
DEFAULT_VOICE_PRESET = "None"
def _load_json_config():
if not CONFIG_JSON_PATH.exists():
return {}
try:
with CONFIG_JSON_PATH.open("r", encoding="utf-8-sig") as handle:
config_data = json.load(handle)
except json.JSONDecodeError as exc:
raise ValueError(f"{CONFIG_JSON_PATH.name} is not valid JSON: {exc}") from exc
except OSError as exc:
raise ValueError(f"Failed to read {CONFIG_JSON_PATH.name}: {exc}") from exc
if not isinstance(config_data, dict):
raise ValueError(f"{CONFIG_JSON_PATH.name} must contain a top-level JSON object.")
return config_data
def _json_value_present(config_data, key):
if key not in config_data:
return False
value = config_data[key]
if value is None:
return False
if isinstance(value, str):
return bool(value.strip())
return True
def _load_env_value(*keys):
for key in keys:
value = os.getenv(key, "").strip()
if value:
return value
return ""
def _load_legacy_config_value(*keys):
config = configparser.ConfigParser()
config.read(LEGACY_CONFIG_PATH, encoding="utf-8")
for key in keys:
value = config.get(LEGACY_CONFIG_SECTION, key, fallback="").strip()
if value:
return value
return ""
def _parse_request_timeout(value):
if isinstance(value, bool):
raise ValueError("request_timeout must be an integer.")
if isinstance(value, int):
timeout = value
else:
try:
timeout = int(str(value).strip())
except (TypeError, ValueError) as exc:
raise ValueError("request_timeout must be an integer.") from exc
if timeout < 5:
raise ValueError("request_timeout must be greater than or equal to 5.")
return timeout
def _parse_poll_interval(value):
if isinstance(value, bool):
raise ValueError("poll_interval must be a number.")
if isinstance(value, (int, float)):
interval = float(value)
else:
try:
interval = float(str(value).strip())
except (TypeError, ValueError) as exc:
raise ValueError("poll_interval must be a number.") from exc
if interval <= 0:
raise ValueError("poll_interval must be greater than 0.")
return interval
def _normalize_area(value):
normalized = str(value).strip().lower()
aliases = {
"global": "global",
"intl": "global",
"international": "global",
"china": "china",
"cn": "china",
}
if normalized in aliases:
return aliases[normalized]
raise ValueError(f"area must be one of: {', '.join(DEFAULT_AREA_OPTIONS)}.")
def _resolve_access_key(config_data):
if _json_value_present(config_data, "access_key"):
return str(config_data["access_key"]).strip()
env_value = _load_env_value("KLINGAI_API_ACCESS_KEY", "KLING_ACCESS_KEY")
if env_value:
return env_value
legacy_value = _load_legacy_config_value("KLINGAI_API_ACCESS_KEY")
if legacy_value:
return legacy_value
raise ValueError(
"An access_key is required. Add access_key to config.local.json, set KLINGAI_API_ACCESS_KEY or "
"KLING_ACCESS_KEY, or add KLINGAI_API_ACCESS_KEY to config.ini."
)
def _resolve_secret_key(config_data):
if _json_value_present(config_data, "secret_key"):
return str(config_data["secret_key"]).strip()
env_value = _load_env_value("KLINGAI_API_SECRET_KEY", "KLING_SECRET_KEY")
if env_value:
return env_value
legacy_value = _load_legacy_config_value("KLINGAI_API_SECRET_KEY")
if legacy_value:
return legacy_value
raise ValueError(
"A secret_key is required. Add secret_key to config.local.json, set KLINGAI_API_SECRET_KEY or "
"KLING_SECRET_KEY, or add KLINGAI_API_SECRET_KEY to config.ini."
)
def _resolve_api_key(config_data):
return _config_or_env_value(config_data, "api_key", ("KLINGAI_API_KEY", "KLING_API_KEY"))
def _resolve_area(config_data):
if _json_value_present(config_data, "area"):
return _normalize_area(config_data["area"])
env_value = _load_env_value("KLINGAI_AREA")
if env_value:
return _normalize_area(env_value)
legacy_value = _load_legacy_config_value("KLINGAI_AREA")
if legacy_value:
return _normalize_area(legacy_value)
return DEFAULT_AREA_OPTIONS[0]
def _resolve_poll_interval(config_data):
if _json_value_present(config_data, "poll_interval"):
return _parse_poll_interval(config_data["poll_interval"])
env_value = _load_env_value("KLINGAI_POLL_INTERVAL")
if env_value:
return _parse_poll_interval(env_value)
legacy_value = _load_legacy_config_value("KLINGAI_POLL_INTERVAL")
if legacy_value:
return _parse_poll_interval(legacy_value)
return DEFAULT_POLL_INTERVAL
def _resolve_request_timeout(config_data):
if _json_value_present(config_data, "request_timeout"):
return _parse_request_timeout(config_data["request_timeout"])
env_value = _load_env_value("KLINGAI_REQUEST_TIMEOUT")
if env_value:
return _parse_request_timeout(env_value)
legacy_value = _load_legacy_config_value("KLINGAI_REQUEST_TIMEOUT")
if legacy_value:
return _parse_request_timeout(legacy_value)
return DEFAULT_REQUEST_TIMEOUT
def _create_runtime_client():
config_data = _load_json_config()
api_key = _resolve_api_key(config_data)
client = Client(
"" if api_key else _resolve_access_key(config_data),
"" if api_key else _resolve_secret_key(config_data),
in_china=_resolve_area(config_data) == "china",
timeout=_resolve_request_timeout(config_data),
poll_interval=_resolve_poll_interval(config_data),
api_key=api_key or None,
)
return client
def _resolve_video_provider_default(config_data=None):
config_data = config_data or _load_json_config()
provider = str(config_data.get("provider_default") or OFFICIAL_PROVIDER).strip().lower()
if provider not in VIDEO_PROVIDER_OPTIONS:
raise ValueError(f"provider_default must be one of: {', '.join(VIDEO_PROVIDER_OPTIONS)}")
return provider
def _resolve_video_provider(provider_name=None):
provider = str(provider_name or _resolve_video_provider_default()).strip().lower()
if provider not in VIDEO_PROVIDER_OPTIONS:
raise ValueError(f"provider must be one of: {', '.join(VIDEO_PROVIDER_OPTIONS)}")
return provider
def _resolve_tencent_secret_id(config_data):
return _config_or_env_value(
config_data,
"tencent_secret_id",
("TENCENTCLOUD_SECRET_ID", "TENCENT_SECRET_ID"),
)
def _resolve_tencent_secret_key(config_data):
return _config_or_env_value(
config_data,
"tencent_secret_key",
("TENCENTCLOUD_SECRET_KEY", "TENCENT_SECRET_KEY"),
)
def _resolve_tencent_region(config_data):
return _config_or_env_value(config_data, "tencent_region", "TENCENT_REGION", "ap-guangzhou")
def _resolve_tencent_sub_app_id(config_data):
raw_value = _config_or_env_value(config_data, "tencent_sub_app_id", "TENCENT_SUB_APP_ID")
if not raw_value:
return 0
try:
return int(str(raw_value).strip())
except (TypeError, ValueError) as exc:
raise ValueError("tencent_sub_app_id must be an integer.") from exc
def _resolve_tencent_poll_interval(config_data):
raw_value = _config_or_env_value(config_data, "tencent_poll_interval", "TENCENT_POLL_INTERVAL", DEFAULT_POLL_INTERVAL)
return _parse_poll_interval(raw_value)
def _resolve_tencent_request_timeout(config_data):
raw_value = _config_or_env_value(config_data, "tencent_request_timeout", "TENCENT_REQUEST_TIMEOUT", DEFAULT_REQUEST_TIMEOUT)
return _parse_request_timeout(raw_value)
def _resolve_tencent_max_wait_seconds(config_data):
raw_value = _config_or_env_value(
config_data,
"tencent_max_wait_seconds",
"TENCENT_MAX_WAIT_SECONDS",
3600,
)
try:
value = int(str(raw_value).strip())
except (TypeError, ValueError) as exc:
raise ValueError("tencent_max_wait_seconds must be an integer.") from exc
if value < 30:
raise ValueError("tencent_max_wait_seconds must be greater than or equal to 30.")
return value
def _resolve_vapeur_api_key(config_data):
return _config_or_env_value(config_data, "vapeur_api_key", "VAPEUR_API_KEY")
def _resolve_vapeur_base_url(config_data):
value = _config_or_env_value(config_data, "vapeur_base_url", "VAPEUR_BASE_URL", "https://api.vapeur.ai")
return str(value).strip().rstrip("/")
def _resolve_vapeur_request_timeout(config_data):
return _parse_request_timeout(_config_or_env_value(
config_data, "vapeur_request_timeout", "VAPEUR_REQUEST_TIMEOUT", DEFAULT_REQUEST_TIMEOUT
))
def _resolve_vapeur_poll_interval(config_data):
return _parse_poll_interval(_config_or_env_value(
config_data, "vapeur_poll_interval", "VAPEUR_POLL_INTERVAL", 5.0
))
def _resolve_vapeur_max_wait_seconds(config_data):
raw_value = _config_or_env_value(config_data, "vapeur_max_wait_seconds", "VAPEUR_MAX_WAIT_SECONDS", 3600)
try:
value = int(str(raw_value).strip())
except (TypeError, ValueError) as exc:
raise ValueError("vapeur_max_wait_seconds must be an integer.") from exc
if value < 30:
raise ValueError("vapeur_max_wait_seconds must be greater than or equal to 30.")
return value
def _create_video_provider(provider_name):
config_data = _load_json_config()
provider_name = str(provider_name or _resolve_video_provider_default(config_data)).strip().lower()
if provider_name == OFFICIAL_PROVIDER:
api_key = _resolve_api_key(config_data)
config = OfficialVideoConfig(
access_key="" if api_key else _resolve_access_key(config_data),
secret_key="" if api_key else _resolve_secret_key(config_data),
api_key=api_key or None,
in_china=_resolve_area(config_data) == "china",
timeout=_resolve_request_timeout(config_data),
poll_interval=_resolve_poll_interval(config_data),
)
return OfficialVideoProvider(config)
if provider_name == TENCENT_VOD_PROVIDER:
secret_id = _resolve_tencent_secret_id(config_data)
secret_key = _resolve_tencent_secret_key(config_data)
sub_app_id = _resolve_tencent_sub_app_id(config_data)
if not secret_id or not secret_key or not sub_app_id:
raise ValueError(
"Tencent VOD provider requires tencent_secret_id, tencent_secret_key, and tencent_sub_app_id in config.local.json or environment variables."
)
config = TencentVodVideoConfig(
secret_id=secret_id,
secret_key=secret_key,
sub_app_id=sub_app_id,
region=_resolve_tencent_region(config_data),
request_timeout=_resolve_tencent_request_timeout(config_data),
poll_interval=_resolve_tencent_poll_interval(config_data),
max_wait_seconds=_resolve_tencent_max_wait_seconds(config_data),
)
return TencentVodVideoProvider(config)
if provider_name == VAPEUR_PROVIDER:
api_key = _resolve_vapeur_api_key(config_data)
if not api_key:
raise ValueError(
"Vapeur provider requires vapeur_api_key in config.local.json or VAPEUR_API_KEY."
)
return VapeurVideoProvider(VapeurVideoConfig(
api_key=api_key,
base_url=_resolve_vapeur_base_url(config_data),
request_timeout=_resolve_vapeur_request_timeout(config_data),
poll_interval=_resolve_vapeur_poll_interval(config_data),
max_wait_seconds=_resolve_vapeur_max_wait_seconds(config_data),
))
raise ValueError(f"Unsupported provider: {provider_name}")
@contextmanager
def _runtime_client():
client = _create_runtime_client()
try:
yield client
finally:
client.close()
@contextmanager
def _video_provider(provider_name):
provider = _create_video_provider(provider_name)
try:
yield provider
finally:
close = getattr(provider, "close", None)
if callable(close):
close()
def _reject_vapeur_unsupported(capability: str) -> None:
if _resolve_video_provider() == VAPEUR_PROVIDER:
raise ValueError(f"Vapeur provider does not support {capability} because the supplied API does not expose this capability.")
def _fetch_image(url, stream=True):
return requests.get(url, stream=stream).content
def _tensor2images(tensor):
np_imgs = numpy.clip(tensor.cpu().numpy() * 255.0, 0.0, 255.0).astype(numpy.uint8)
return [PIL.Image.fromarray(np_img) for np_img in np_imgs]
def _images2tensor(images):
if isinstance(images, Iterable):
return torch.stack([torch.from_numpy(numpy.array(image)).float() / 255.0 for image in images])
return torch.from_numpy(numpy.array(images)).unsqueeze(0).float() / 255.0
def _decode_image(data_bytes, rtn_mask=False):
with io.BytesIO(data_bytes) as bytes_io:
img = PIL.Image.open(bytes_io)
if not rtn_mask:
img = img.convert('RGB')
elif 'A' in img.getbands():
img = img.getchannel('A')
else:
img = None
return img
def _encode_image(img, mask=None):
if mask is not None:
img = img.copy()
img.putalpha(mask)
with io.BytesIO() as bytes_io:
if mask is not None:
img.save(bytes_io, format='PNG')
else:
img.save(bytes_io, format='JPEG')
data_bytes = bytes_io.getvalue()
return data_bytes
def _image_to_base64(image):
if image is None:
return None
return base64.b64encode(_encode_image(_tensor2images(image)[0])).decode("utf-8")
def _image_batch_to_base64_list(image):
if image is None:
return []
return [base64.b64encode(_encode_image(frame)).decode("utf-8") for frame in _tensor2images(image)]
def _upload_image_batch_references(image):
if image is None:
return []
uploaded_urls = []
for frame in _tensor2images(image):
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as handle:
temp_path = handle.name
try:
frame.save(temp_path, format="PNG")
uploaded_urls.append(_upload_file_to_temporary_media_host(temp_path))
finally:
if os.path.exists(temp_path):
os.remove(temp_path)
return uploaded_urls
def _validate_clothing_dupe_image_inputs(image_inputs, field_name, required):
images = []
for image in image_inputs:
if image is not None:
images.extend(_tensor2images(image))
if required and not images:
raise ValueError(f"{field_name} must contain at least one image.")
if len(images) > 5:
raise ValueError(f"{field_name} supports at most 5 images.")
for index, frame in enumerate(images, start=1):
width, height = frame.size
if width < 300 or height < 300:
raise ValueError(f"{field_name}[{index}] must be at least 300px in both dimensions.")
aspect_ratio = width / height
if not 0.4 <= aspect_ratio <= 2.5:
raise ValueError(f"{field_name}[{index}] aspect ratio must be between 1:2.5 and 2.5:1.")
return images
def _validate_clothing_dupe_video(video):
if video is None:
raise ValueError("source_video is required.")
get_duration = getattr(video, "get_duration", None)
if callable(get_duration):
try:
duration = float(get_duration())
except Exception:
duration = None
if duration is not None and not 5 <= duration <= 180:
raise ValueError("source_video duration must be between 5 and 180 seconds.")
def _parse_json_input(raw_value, field_name):
if raw_value is None:
return None
if isinstance(raw_value, (dict, list)):
return raw_value
if not isinstance(raw_value, str):
raise ValueError(f"{field_name} must be a JSON string.")
stripped = raw_value.strip()
if not stripped:
return None
try:
return json.loads(stripped)
except json.JSONDecodeError as exc:
raise ValueError(f"{field_name} must be valid JSON: {exc}") from exc
def _build_watermark_info(enabled):
if not enabled:
return None
return {"enabled": True}
def _clean_optional_text(value):
if not isinstance(value, str):
return value
value = value.strip()
return value or None
def _contains_cjk_text(value):
return bool(re.search(r"[\u3400-\u9fff]", str(value or "")))
def _normalize_tts_voice_language(text, voice_language):
voice_language = str(voice_language or "").strip() or "zh"
if _contains_cjk_text(text) and voice_language == "en":
print(f"[{NODE_PREFIX}] TTS text contains Chinese characters; using voice_language='zh' instead of 'en'.")
return "zh"
if voice_language not in {"zh", "en"}:
return "zh" if _contains_cjk_text(text) else "en"
return voice_language
def _set_if_present(target, field_name, value):
if value is not None:
setattr(target, field_name, value)
def _resolve_voice_id(voice_preset):
if not isinstance(voice_preset, str):
return None
voice_preset = voice_preset.strip()
if not voice_preset or voice_preset == "None":
return None
if voice_preset in VOICE_PRESET_VALUE_BY_LABEL:
return VOICE_PRESET_VALUE_BY_LABEL[voice_preset]
if voice_preset in LIPSYNC_AUDIO_TYPES.values():
return voice_preset
if "|" in voice_preset:
candidate = voice_preset.rsplit("|", 1)[-1].strip()
if candidate:
return candidate
return voice_preset
def _build_voice_list_from_preset(voice_preset):
voice_id = _resolve_voice_id(voice_preset)
if not voice_id:
return None
return [{"voice_id": voice_id}]
def _normalize_voice_reference(voice):
if voice is None:
return None
if isinstance(voice, dict):
voice_id = str(voice.get("voice_id") or voice.get("id") or "").strip()
if not voice_id:
raise ValueError("Voice payload is missing voice_id.")
return {"voice_id": voice_id}
if isinstance(voice, str):
voice_id = voice.strip()
if not voice_id:
return None
return {"voice_id": voice_id}
raise ValueError("Voice must be a dict or string.")
def _normalize_voice_list(voices):
if voices is None:
return None
if isinstance(voices, str):
cleaned = voices.strip()
if not cleaned:
return None
if cleaned[0] in "[{":
decoded = _parse_json_input(cleaned, "custom_voice_list")
voices = decoded
else:
voices = [line.strip() for line in cleaned.replace(",", "\n").splitlines()]
if isinstance(voices, dict):
voices = [voices]
normalized = []
for voice in voices:
payload = _normalize_voice_reference(voice)
if payload:
normalized.append(payload)
return normalized or None
def _validate_voice_prompt_markers(prompt, voice_list):
if not voice_list:
return
prompt_text = str(prompt or "")
missing_markers = [
f"<<<voice_{index}>>>"
for index in range(1, len(voice_list) + 1)
if f"<<<voice_{index}>>>" not in prompt_text
]
if missing_markers:
raise ValueError(
"custom_voice_list requires prompt markers: "
+ ", ".join(missing_markers)
+ "."
)
def _apply_prompt_controls(target, capability, prompt=None, negative_prompt=None, cfg_scale=None):
cleaned_prompt = _clean_optional_text(prompt)
cleaned_negative_prompt = _clean_optional_text(negative_prompt)
_set_if_present(target, "prompt", cleaned_prompt)
if cleaned_negative_prompt and capability.get("supports_negative_prompt", True):
target.negative_prompt = cleaned_negative_prompt
if cfg_scale is not None and capability.get("supports_cfg_scale", True):
target.cfg_scale = cfg_scale
def _normalize_requested_mode(model_name, capability, mode):
supported_modes = capability.get("modes") or []
if not supported_modes:
return mode
if mode in supported_modes:
return mode
if len(supported_modes) == 1 and mode in (None, "", "std"):
resolved_mode = supported_modes[0]
print(f"[{NODE_PREFIX}] normalized mode for {model_name}: {mode!r} -> {resolved_mode!r}")
return resolved_mode
return mode
def _raise_with_model_guidance(exc, model_name):
if (
isinstance(exc, KLingAPIError)
and exc.code == 1201
and isinstance(exc.message, str)
and "model is not supported" in exc.message.lower()
and model_name in {"kling-v3-omni", "kling-video-o1"}
):
raise ValueError(
f"Kling API returned `1201 model is not supported` for `{model_name}`. "
f"This node now strips legacy tuning fields for these newer models, so if the error still occurs "
f"it is likely the live endpoint/account has not enabled that model yet. "
f"Please verify the model is available for your current key on the active endpoint, or temporarily use `kling-v3`."
) from exc
raise exc
def _raise_with_image_model_guidance(exc, model_name):
if (
isinstance(exc, KLingAPIError)
and exc.code == 1201
and isinstance(exc.message, str)
and "model is not supported" in exc.message.lower()
):
raise ValueError(
f"Kling API returned `1201 model is not supported` for image model `{model_name}`. "
f"On the Beijing endpoint this usually means the current key has not enabled that image model yet. "
f"Models like `kling-v3` are recognized, while some newer image entries may still be unavailable on a given account."
) from exc
raise exc
def _preferred_media_url(media_info, prefer_watermark=False):
watermark_url = getattr(media_info, "watermark_url", None)
if prefer_watermark and isinstance(watermark_url, str) and watermark_url.strip():
return watermark_url.strip()
for field_name in ("url", "video_url", "resource_url", "image_url"):
candidate = getattr(media_info, field_name, None)
if isinstance(candidate, str) and candidate.strip():
return candidate.strip()
if isinstance(watermark_url, str) and watermark_url.strip():
return watermark_url.strip()
return None
def _extract_first_video_result(response, task_name):
task_status = getattr(response, "task_status", None)
task_status_msg = getattr(response, "task_status_msg", None)
task_result = getattr(response, "task_result", None)
videos = getattr(task_result, "videos", None) or []
if task_status not in (None, "", "succeed", "success", "completed"):
details = f"{task_name} failed with task_status={task_status!r}"
if task_status_msg:
details += f": {task_status_msg}"
raise ValueError(details)
if not videos:
details = f"{task_name} did not return any videos."
if task_status:
details += f" task_status={task_status!r}."
if task_status_msg:
details += f" Details: {task_status_msg}"
raise ValueError(details)
for video_info in videos:
video_url = _preferred_media_url(video_info)
if video_url:
return video_url, getattr(video_info, "id", "")
details = f"{task_name} completed but returned an empty video URL."
if task_status:
details += f" task_status={task_status!r}."
if task_status_msg:
details += f" Details: {task_status_msg}"
raise ValueError(details)
def _log_final_unit_deduction(response, task_name):
deduction = getattr(response, "final_unit_deduction", None)
if deduction is not None:
print(f"[{NODE_PREFIX}] final_unit_deduction | {task_name}: {deduction}")
def _collect_element_types(element_list):
if not element_list:
return []
element_types = []
for element in element_list:
if isinstance(element, dict):
element_types.append(element.get("type") or element.get("element_type"))
return [element_type for element_type in element_types if element_type]
def _build_url_image2video_request(request, image, image_tail, image_list):
if request.image_base64 is None or image is None:
raise ValueError("This provider requires a primary IMAGE input uploaded as a URL.")
primary_urls = _upload_image_batch_references(image)
reference_urls = _upload_image_batch_references(image_list) if image_list is not None else []
merged_image_urls = [*(primary_urls[:1] if primary_urls else []), *reference_urls]
request.image_urls = [url for url in merged_image_urls if url]
request.image_base64 = None
if request.image_tail_base64 is not None and image_tail is not None:
tail_urls = _upload_image_batch_references(image_tail)
request.image_tail_url = tail_urls[0] if tail_urls else None
request.image_tail_base64 = None
return request
def _build_tencent_vod_text2video_request(request):
return request
def _normalize_element_reference(element):
if element is None:
return None
if isinstance(element, dict):
element_id = element.get("element_id") or element.get("id")
element_type = element.get("type") or element.get("element_type")
if not element_id:
raise ValueError("Element payload is missing element_id.")
payload = {"element_id": element_id}
if element_type:
payload["type"] = element_type
return payload
if isinstance(element, str):
element_id = element.strip()
if not element_id:
return None
return {"element_id": element_id}
raise ValueError("Element must be a dict or string.")
def _normalize_element_list(elements):
if elements is None:
return None
if isinstance(elements, dict):
elements = [elements]
normalized = []
for element in elements:
payload = _normalize_element_reference(element)
if payload:
normalized.append(payload)
return normalized or None
def _build_camera_control(camera_control_type, camera_control_config, camera_control_value):
if camera_control_type == "None":
return None
camera_control = CameraControl()
camera_control.type = camera_control_type
if camera_control_type == "simple":
camera_control.config = CameraControlConfig()
setattr(camera_control.config, camera_control_config, camera_control_value)
return camera_control
def _element_result_to_payload(data):
if data is None:
return None
if hasattr(data, "dict"):
return data.dict(exclude_none=True)
if isinstance(data, dict):
return {key: value for key, value in data.items() if value is not None}
return data
def _extract_elements_from_response(data):
if data is None:
return []
task_result = data.get("task_result") if isinstance(data, dict) else getattr(data, "task_result", None)
if isinstance(task_result, dict):
if task_result.get("elements"):
return task_result["elements"]
if task_result.get("element"):
return [task_result["element"]]
if task_result is not None:
elements = getattr(task_result, "elements", None)
if elements:
return [_element_result_to_payload(element) for element in elements]
element = getattr(task_result, "element", None)
if element:
return [_element_result_to_payload(element)]
if isinstance(data, dict) and data.get("elements"):
return data["elements"]
if isinstance(data, dict) and data.get("element"):
return [data["element"]]
return []
def _voice_result_to_payload(data):
if data is None:
return None
if hasattr(data, "dict"):
return data.dict(exclude_none=True)
if isinstance(data, dict):
return {key: value for key, value in data.items() if value is not None}
return {
key: value
for key, value in {
"voice_id": getattr(data, "voice_id", None),
"voice_name": getattr(data, "voice_name", None),
"trial_url": getattr(data, "trial_url", None),
"owned_by": getattr(data, "owned_by", None),
}.items()
if value is not None
}
def _extract_custom_voice_result(response):
task_result = getattr(response, "task_result", None)
voices = getattr(task_result, "voices", None) if task_result is not None else None
if not voices:
task_status = getattr(response, "task_status", None)
task_status_msg = getattr(response, "task_status_msg", None)
details = "custom voice task did not return any voices."
if task_status:
details += f" task_status={task_status!r}."
if task_status_msg:
details += f" Details: {task_status_msg}"
raise ValueError(details)
payload = _voice_result_to_payload(voices[0]) or {}
if not payload.get("voice_id"):
raise ValueError("custom voice task completed but returned an empty voice_id.")
return payload
def _extract_tts_audio_result(response):
task_result = getattr(response, "task_result", None)
audios = getattr(task_result, "audios", None) if task_result is not None else None
if not audios:
task_status = getattr(response, "task_status", None)
task_status_msg = getattr(response, "task_status_msg", None)
details = "tts task did not return any audios."
if task_status:
details += f" task_status={task_status!r}."
if task_status_msg:
details += f" Details: {task_status_msg}"
raise ValueError(details)
audio = audios[0]
if hasattr(audio, "dict"):
payload = audio.dict(exclude_none=True)
elif isinstance(audio, dict):
payload = {key: value for key, value in audio.items() if value is not None}
else:
payload = {
"id": getattr(audio, "id", None),
"url": getattr(audio, "url", None),
"duration": getattr(audio, "duration", None),
}
if not payload.get("url"):
raise ValueError("tts task completed but returned an empty audio URL.")
return payload
def _load_audio_from_url(audio_url, save_directory, filename_prefix="audio"):
try:
response = requests.get(
audio_url,
timeout=30,
stream=True,
headers={'User-Agent': 'Mozilla/5.0'}
)
response.raise_for_status()
parsed_url = urllib.parse.urlparse(audio_url)
ext = Path(parsed_url.path).suffix or '.mp3'
timestamp = int(time.time() * 1000) # 毫秒级时间戳
filename = f"{filename_prefix}_{timestamp}{ext}"
file_path = os.path.join(save_directory, filename)
os.makedirs(save_directory, exist_ok=True)
with open(file_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
return file_path # 返回完整路径
except requests.exceptions.Timeout:
raise Exception(f"time out: {audio_url}")
except requests.exceptions.HTTPError as e:
raise Exception(f"http error {e.response.status_code}: {audio_url}")
except requests.exceptions.RequestException as e:
raise Exception(f"download failed: {e}")
except IOError as e:
raise Exception(f"save failed: {e}")
except Exception as e:
raise Exception(f"other failed: {e}")
def _saved_result(filename, subfolder, folder_type):
return {
"filename": filename,
"subfolder": subfolder,
"type": folder_type,
}
def _register_output_asset(file_path):
try:
import app.assets.services.ingest as asset_ingest
except Exception:
return
ingest_existing_file = getattr(asset_ingest, "ingest_existing_file", None)
if not callable(ingest_existing_file):
return
try:
ingest_existing_file(file_path)
except Exception:
return
def _build_local_media_view_url(filename, subfolder, folder_type):
query = [
f"type={urllib.parse.quote(str(folder_type), safe='')}",
f"filename={urllib.parse.quote(str(filename), safe='')}",
]
if subfolder:
query.append(f"subfolder={urllib.parse.quote(str(subfolder), safe='')}")
return "/api/view?" + "&".join(query)
def _normalize_tmpfiles_download_url(page_url):
normalized = str(page_url or "").strip()
if not normalized:
raise ValueError("Upload service did not return a file URL.")
parsed = urllib.parse.urlparse(normalized)
if parsed.netloc.lower() != "tmpfiles.org":
return normalized
path = parsed.path.strip("/")
if not path:
raise ValueError("Upload service returned an invalid tmpfiles URL.")
if path.startswith("dl/"):
return f"https://tmpfiles.org/{path}"
return f"https://tmpfiles.org/dl/{path}"
def _windows_path_to_wsl_path(file_path):
raw_path = os.fspath(file_path).strip().strip('"')
match = re.match(r"^([A-Za-z]):[\\/](.*)$", raw_path)
if os.name != "nt" and match:
drive = match.group(1).lower()
rest = match.group(2).replace("\\", "/")
return f"/mnt/{drive}/{rest}"
return raw_path
def _resolve_upload_file_path(file_path):
raw_path = str(file_path or "").strip()
if not raw_path:
raise ValueError("Upload file path is required.")
converted_path = _windows_path_to_wsl_path(raw_path)
candidates = [converted_path]
if not os.path.isabs(converted_path):
candidates.append(os.path.abspath(converted_path))
try:
candidates.append(folder_paths.get_annotated_filepath(raw_path))
except Exception:
pass
seen = set()
for candidate in candidates:
if not candidate:
continue
normalized_path = os.path.abspath(os.fspath(candidate))
if normalized_path in seen:
continue
seen.add(normalized_path)
if os.path.isfile(normalized_path):
return normalized_path
raise ValueError(f"Upload file does not exist or is not a file: {converted_path}")
def _read_file_as_base64(file_path):
normalized_path = _resolve_upload_file_path(file_path)
with open(normalized_path, "rb") as handle:
return base64.b64encode(handle.read()).decode("utf-8")
def _normalize_catbox_upload_url(raw_url):
normalized = str(raw_url or "").strip()
if not normalized:
raise ValueError("catbox.moe upload did not return a file URL.")
if normalized.upper().startswith("ERROR"):
raise ValueError(f"catbox.moe upload failed: {normalized}")
parsed = urllib.parse.urlparse(normalized)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError(f"catbox.moe upload returned an invalid URL: {normalized}")
return normalized
def _is_http_url(value):
parsed = urllib.parse.urlparse(str(value or "").strip())
return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
def _config_or_env_value(config_data, json_key, env_key, default=""):
if _json_value_present(config_data, json_key):
return str(config_data[json_key]).strip()
env_keys = env_key if isinstance(env_key, (tuple, list)) else (env_key,)
env_value = _load_env_value(*env_keys)
if env_value:
return env_value
return default
def _normalize_oss_endpoint(endpoint):
raw_endpoint = str(endpoint or "").strip().rstrip("/")
if not raw_endpoint:
raise ValueError("oss_endpoint is required.")
if "://" not in raw_endpoint:
raw_endpoint = f"https://{raw_endpoint}"
parsed = urllib.parse.urlparse(raw_endpoint)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError("oss_endpoint must be a valid OSS endpoint host or URL.")
if parsed.path not in {"", "/"}:
raise ValueError("oss_endpoint must not include a path.")
return parsed.scheme, parsed.netloc
def _normalize_oss_prefix(prefix):
normalized = str(prefix or "").strip().strip("/")
return normalized or LOCAL_MEDIA_UPLOAD_SUBFOLDER
def _parse_oss_signed_url_expires(value):
if value in (None, ""):
return 24 * 60 * 60
try:
expires = int(str(value).strip())
except (TypeError, ValueError) as exc:
raise ValueError("oss_signed_url_expires must be an integer number of seconds.") from exc
if expires <= 0:
raise ValueError("oss_signed_url_expires must be greater than 0.")
return expires
def _resolve_oss_upload_config():
config_data = _load_json_config()
endpoint = _config_or_env_value(config_data, "oss_endpoint", "OSS_ENDPOINT")
access_key_id = _config_or_env_value(config_data, "oss_access_key_id", "OSS_ACCESS_KEY_ID")
access_key_secret = _config_or_env_value(config_data, "oss_access_key_secret", "OSS_ACCESS_KEY_SECRET")
bucket = _config_or_env_value(config_data, "oss_bucket", "OSS_BUCKET")
prefix = _config_or_env_value(config_data, "oss_prefix", "OSS_PREFIX", LOCAL_MEDIA_UPLOAD_SUBFOLDER)
signed_url_expires = _config_or_env_value(
config_data,
"oss_signed_url_expires",
"OSS_SIGNED_URL_EXPIRES",
24 * 60 * 60,
)
required_values = {
"oss_endpoint": endpoint,
"oss_access_key_id": access_key_id,
"oss_access_key_secret": access_key_secret,
"oss_bucket": bucket,
}
present_count = sum(1 for value in required_values.values() if value)
if present_count == 0:
return None
missing_keys = [key for key, value in required_values.items() if not value]
if missing_keys:
raise ValueError(
"OSS upload config is incomplete; set "
+ ", ".join(missing_keys)
+ " in config.local.json or environment variables."
)
scheme, endpoint_host = _normalize_oss_endpoint(endpoint)
return {
"scheme": scheme,
"endpoint_host": endpoint_host,
"access_key_id": access_key_id,
"access_key_secret": access_key_secret,
"bucket": str(bucket).strip(),
"prefix": _normalize_oss_prefix(prefix),
"signed_url_expires": _parse_oss_signed_url_expires(signed_url_expires),
}
def _oss_signature(access_key_secret, string_to_sign):
digest = hmac.new(
access_key_secret.encode("utf-8"),
string_to_sign.encode("utf-8"),
hashlib.sha1,
).digest()
return base64.b64encode(digest).decode("utf-8")
def _build_oss_object_key(file_path, prefix):
filename = os.path.basename(os.fspath(file_path))
safe_filename = re.sub(r"[^A-Za-z0-9._-]+", "_", filename).strip("._")
if not safe_filename:
safe_filename = "media"
date_folder = time.strftime("%Y%m%d", time.gmtime())
return f"{prefix}/{date_folder}/{uuid.uuid4().hex}-{safe_filename}"
def _build_oss_url(oss_config, object_key, query=None):
quoted_key = urllib.parse.quote(object_key, safe="/")
base_url = (
f"{oss_config['scheme']}://{oss_config['bucket']}."
f"{oss_config['endpoint_host']}/{quoted_key}"
)
if not query:
return base_url
return base_url + "?" + urllib.parse.urlencode(query)
def _build_oss_authorization_header(oss_config, method, object_key, content_type, date):
string_to_sign = (
f"{method}\n"
f"\n"
f"{content_type}\n"
f"{date}\n"
f"/{oss_config['bucket']}/{object_key}"
)
signature = _oss_signature(oss_config["access_key_secret"], string_to_sign)
return f"OSS {oss_config['access_key_id']}:{signature}"
def _build_oss_signed_download_url(oss_config, object_key):
expires = int(time.time()) + int(oss_config["signed_url_expires"])
string_to_sign = f"GET\n\n\n{expires}\n/{oss_config['bucket']}/{object_key}"
signature = _oss_signature(oss_config["access_key_secret"], string_to_sign)
return _build_oss_url(
oss_config,
object_key,
{
"OSSAccessKeyId": oss_config["access_key_id"],
"Expires": str(expires),
"Signature": signature,
},
)
def _upload_file_to_oss(file_path, timeout=DEFAULT_UPLOAD_TIMEOUT, oss_config=None):
normalized_path = _resolve_upload_file_path(file_path)
oss_config = oss_config or _resolve_oss_upload_config()
if not oss_config:
raise ValueError("OSS upload config is not configured.")
object_key = _build_oss_object_key(normalized_path, oss_config["prefix"])
content_type = mimetypes.guess_type(normalized_path)[0] or "application/octet-stream"
upload_url = _build_oss_url(oss_config, object_key)
for attempt in range(1, TMPFILES_UPLOAD_RETRY_COUNT + 1):
try:
date = email.utils.formatdate(usegmt=True)
authorization = _build_oss_authorization_header(
oss_config,
"PUT",
object_key,
content_type,
date,
)
with open(normalized_path, "rb") as handle:
response = requests.put(
upload_url,
data=handle,
headers={
"Authorization": authorization,
"Content-Type": content_type,
"Date": date,
},
timeout=float(timeout),
)
response.raise_for_status()
return _build_oss_signed_download_url(oss_config, object_key)
except requests.RequestException as exc:
if attempt >= TMPFILES_UPLOAD_RETRY_COUNT:
raise ConnectionError(
"OSS media upload failed after multiple attempts. "
f"Check OSS endpoint, bucket permissions, and network access: {exc}"
) from exc
print(
f"[{NODE_PREFIX}] OSS upload retry {attempt}/{TMPFILES_UPLOAD_RETRY_COUNT} "
f"after transient error: {exc}"
)
time.sleep(TMPFILES_UPLOAD_RETRY_DELAY)
raise ConnectionError("OSS media upload failed.")
def _upload_file_to_tmpfiles(file_path, timeout=DEFAULT_UPLOAD_TIMEOUT):
normalized_path = _resolve_upload_file_path(file_path)
file_size = os.path.getsize(normalized_path)
if file_size > TMPFILES_MAX_SIZE_BYTES:
raise ValueError("Local media file exceeds tmpfiles.org's 100 MB upload limit.")
filename = os.path.basename(normalized_path)
for attempt in range(1, TMPFILES_UPLOAD_RETRY_COUNT + 1):
try:
with open(normalized_path, "rb") as handle:
response = requests.post(
TMPFILES_UPLOAD_API_URL,
files={"file": (filename, handle)},
timeout=float(timeout),
)
break
except requests.RequestException as exc:
if attempt >= TMPFILES_UPLOAD_RETRY_COUNT:
raise ConnectionError(
"Temporary media upload failed after multiple attempts. "
f"tmpfiles.org may be temporarily unavailable: {exc}"
) from exc
print(
f"[{NODE_PREFIX}] tmpfiles upload retry {attempt}/{TMPFILES_UPLOAD_RETRY_COUNT} "
f"after transient error: {exc}"
)
time.sleep(TMPFILES_UPLOAD_RETRY_DELAY)
try:
response.raise_for_status()
except requests.RequestException as exc:
raise ConnectionError(f"Temporary media upload failed: {exc}") from exc
try:
payload = response.json()
except ValueError as exc:
raise ValueError("Temporary media upload returned invalid JSON.") from exc
if payload.get("status") != "success":
raise ValueError(f"Temporary media upload failed: {payload}")
page_url = payload.get("data", {}).get("url")
return _normalize_tmpfiles_download_url(page_url)
def _upload_file_to_catbox(file_path, timeout=DEFAULT_UPLOAD_TIMEOUT):
normalized_path = _resolve_upload_file_path(file_path)
filename = os.path.basename(normalized_path)
for attempt in range(1, TMPFILES_UPLOAD_RETRY_COUNT + 1):
try:
with open(normalized_path, "rb") as handle:
response = requests.post(
CATBOX_UPLOAD_API_URL,
data={"reqtype": "fileupload"},
files={"fileToUpload": (filename, handle)},
timeout=float(timeout),
)
break
except requests.RequestException as exc:
if attempt >= TMPFILES_UPLOAD_RETRY_COUNT:
raise ConnectionError(
"Temporary media upload failed after multiple attempts. "
f"catbox.moe may be temporarily unavailable: {exc}"
) from exc
print(
f"[{NODE_PREFIX}] catbox upload retry {attempt}/{TMPFILES_UPLOAD_RETRY_COUNT} "
f"after transient error: {exc}"
)
time.sleep(TMPFILES_UPLOAD_RETRY_DELAY)
try:
response.raise_for_status()
except requests.RequestException as exc:
raise ConnectionError(f"Temporary media upload failed: {exc}") from exc
return _normalize_catbox_upload_url(response.text)
def _upload_file_to_temporary_media_host(file_path, timeout=DEFAULT_UPLOAD_TIMEOUT):
normalized_path = _resolve_upload_file_path(file_path)
file_size = os.path.getsize(normalized_path)
failures = []
uploaders = []
try:
oss_config = _resolve_oss_upload_config()
except Exception as exc:
oss_config = None
failures.append(f"aliyun OSS: {exc}")
if oss_config is not None:
uploaders.append(
(
"aliyun OSS",
lambda path, timeout=DEFAULT_UPLOAD_TIMEOUT, _oss_config=oss_config: _upload_file_to_oss(
path,
timeout=timeout,
oss_config=_oss_config,
),
)
)
if file_size <= TMPFILES_MAX_SIZE_BYTES:
uploaders.append(("tmpfiles.org", _upload_file_to_tmpfiles))
else:
failures.append("tmpfiles.org: skipped because the file exceeds the 100 MB upload limit")
uploaders.append(("catbox.moe", _upload_file_to_catbox))
for service_name, uploader in uploaders:
try:
url = uploader(normalized_path, timeout=timeout)
if failures:
print(f"[{NODE_PREFIX}] temporary media upload succeeded via {service_name}.")
return url
except Exception as exc:
failures.append(f"{service_name}: {exc}")
print(f"[{NODE_PREFIX}] temporary media upload via {service_name} failed: {exc}")
raise ConnectionError(
"Temporary media upload failed through all available upload services. "
+ " | ".join(failures)
)
def _upload_video_reference(video):
if video is None:
raise ValueError("video is required.")
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as handle:
temp_path = handle.name
try:
video.save_to(temp_path)
return _upload_file_to_temporary_media_host(temp_path)
finally:
if os.path.exists(temp_path):
os.remove(temp_path)
def _write_audio_to_temp_wav(audio):
if audio is None:
raise ValueError("audio is required.")
if not isinstance(audio, dict):
raise ValueError("audio must be a ComfyUI AUDIO object.")
if "waveform" not in audio or "sample_rate" not in audio:
raise ValueError("audio must contain waveform and sample_rate.")
waveform = audio["waveform"]
if hasattr(waveform, "detach"):
waveform = waveform.detach()
if hasattr(waveform, "cpu"):
waveform = waveform.cpu()
if not isinstance(waveform, torch.Tensor):
waveform = torch.as_tensor(waveform)
if waveform.dim() == 3:
waveform = waveform[0]
elif waveform.dim() == 1:
waveform = waveform.unsqueeze(0)
elif waveform.dim() != 2:
raise ValueError("audio waveform must have shape [batch, channels, samples], [channels, samples], or [samples].")
if waveform.shape[0] <= 0 or waveform.shape[1] <= 0:
raise ValueError("audio waveform must contain at least one channel and one sample.")
try:
sample_rate = int(audio["sample_rate"])
except (TypeError, ValueError) as exc:
raise ValueError("audio sample_rate must be an integer.") from exc
if sample_rate <= 0:
raise ValueError("audio sample_rate must be greater than 0.")
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as handle:
temp_path = handle.name
try:
waveform = waveform.to(dtype=torch.float32).numpy()
pcm = numpy.clip(waveform, -1.0, 1.0)
pcm = (pcm.T * 32767.0).astype(numpy.int16)
with wave.open(temp_path, "wb") as wav_file:
wav_file.setnchannels(int(waveform.shape[0]))
wav_file.setsampwidth(2)
wav_file.setframerate(sample_rate)
wav_file.writeframes(pcm.tobytes())
return temp_path
except Exception:
if os.path.exists(temp_path):
os.remove(temp_path)
raise
def _upload_audio_reference(audio):
temp_path = _write_audio_to_temp_wav(audio)
try:
return _upload_file_to_temporary_media_host(temp_path)
finally:
if os.path.exists(temp_path):
os.remove(temp_path)
def _audio_duration_ms(audio):
if not isinstance(audio, dict) or "waveform" not in audio or "sample_rate" not in audio:
raise ValueError("audio must contain waveform and sample_rate.")
waveform = audio["waveform"]
shape = getattr(waveform, "shape", None)
if not shape or len(shape) < 1:
raise ValueError("audio waveform must contain at least one sample.")
try:
sample_count = int(shape[-1])
sample_rate = int(audio["sample_rate"])
except (TypeError, ValueError) as exc:
raise ValueError("audio waveform shape and sample_rate must be numeric.") from exc
if sample_count <= 0 or sample_rate <= 0:
raise ValueError("audio must contain samples and have a positive sample_rate.")
return int(round(sample_count * 1000 / sample_rate))
def _upload_image_reference(image):
if image is None:
raise ValueError("image is required.")
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as handle:
temp_path = handle.name
try:
first_frame = _tensor2images(image)[0]
first_frame.save(temp_path, format="PNG")
return _upload_file_to_temporary_media_host(temp_path)
finally:
if os.path.exists(temp_path):
os.remove(temp_path)
def _find_ffmpeg_path():
try:
from imageio_ffmpeg import get_ffmpeg_exe
ffmpeg_path = get_ffmpeg_exe()
if ffmpeg_path:
return ffmpeg_path
except Exception:
pass
ffmpeg_path = shutil.which("ffmpeg") or shutil.which("ffmpeg.exe")
if ffmpeg_path:
return ffmpeg_path
raise RuntimeError(
"ffmpeg is required to encode reference_video_frames. Install imageio-ffmpeg or make ffmpeg available on PATH."
)
def _resolve_reference_video_fps(reference_video_info=None):
if isinstance(reference_video_info, dict):
for key in ("loaded_fps", "source_fps"):
value = reference_video_info.get(key)
try:
fps = float(value)
except (TypeError, ValueError):
continue
if fps > 0:
return fps
return DEFAULT_REFERENCE_VIDEO_FPS
def _format_duration_value(seconds):
value = float(seconds)
if value <= 0:
raise ValueError("duration must be greater than 0.")
if value.is_integer():
return str(int(value))
return f"{value:.3f}".rstrip("0").rstrip(".")
def _resolve_motion_control_duration(
duration,
reference_video_input=None,
reference_video_frames=None,
reference_video_info=None,
):
normalized = str(duration or "").strip() or "auto"
if normalized != "auto":
return normalized
if isinstance(reference_video_info, dict):
for key in ("loaded_duration", "source_duration"):
value = reference_video_info.get(key)
try:
return _format_duration_value(value)
except (TypeError, ValueError):
continue
if reference_video_input is not None and hasattr(reference_video_input, "get_duration"):
try:
return _format_duration_value(reference_video_input.get_duration())
except Exception:
pass
if reference_video_frames is not None:
frame_count = None
shape = getattr(reference_video_frames, "shape", None)
if shape and len(shape) > 0:
try:
frame_count = int(shape[0])
except Exception:
frame_count = None
if frame_count is None:
try:
frame_count = len(reference_video_frames)
except Exception:
frame_count = None
if frame_count and frame_count > 0:
fps = _resolve_reference_video_fps(reference_video_info)
return _format_duration_value(frame_count / fps)
raise ValueError(
"duration='auto' requires reference_video_input or reference_video_frames with usable duration metadata. "
"Set duration explicitly when using a direct reference_video URL."
)
def _encode_reference_video_frames_to_mp4(reference_video_frames, reference_video_info=None):
images = _tensor2images(reference_video_frames)
if not images:
raise ValueError("reference_video_frames must contain at least one frame.")
first_frame = images[0].convert("RGB")
width, height = first_frame.size
fps = _resolve_reference_video_fps(reference_video_info)
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as handle:
temp_path = handle.name
ffmpeg_path = _find_ffmpeg_path()
command = [
ffmpeg_path,
"-v",
"error",
"-y",
"-f",
"rawvideo",
"-pix_fmt",
"rgb24",
"-s",
f"{width}x{height}",
"-r",
f"{fps:.6f}",
"-i",
"-",
"-an",
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
temp_path,
]
process = subprocess.Popen(
command,
stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
)
try:
for frame in images:
rgb_frame = frame.convert("RGB")
if rgb_frame.size != (width, height):
raise ValueError("reference_video_frames must all have the same size.")
process.stdin.write(rgb_frame.tobytes())
process.stdin.close()
stderr = process.stderr.read()
return_code = process.wait()
except Exception:
process.kill()
process.wait()
if os.path.exists(temp_path):
os.remove(temp_path)
raise
if return_code != 0:
if os.path.exists(temp_path):
os.remove(temp_path)
error_message = stderr.decode("utf-8", errors="ignore").strip()
raise RuntimeError(f"Failed to encode reference_video_frames with ffmpeg: {error_message or return_code}")
return temp_path
def _upload_reference_video_frames(reference_video_frames, reference_video_info=None):
temp_path = _encode_reference_video_frames_to_mp4(reference_video_frames, reference_video_info)
try:
return _upload_file_to_temporary_media_host(temp_path)
finally:
if os.path.exists(temp_path):
os.remove(temp_path)
def _resolve_motion_control_reference_video(
reference_video,
reference_video_input=None,
reference_video_frames=None,
reference_video_info=None,
):
if reference_video_input is not None:
return _upload_video_reference(reference_video_input)
if reference_video_frames is not None:
return _upload_reference_video_frames(reference_video_frames, reference_video_info)
if isinstance(reference_video, str) and reference_video.strip():
normalized = reference_video.strip()
if _is_http_url(normalized):
return normalized
raise ValueError("reference_video must be an http(s) URL when provided.")
return ""
def _resolve_lip_sync_video_reference(
video_id=None,
video_url=None,
video_file="",
video_input=None,
video_frames=None,
video_info=None,
):
video_id = str(video_id or "").strip()
video_url = str(video_url or "").strip()
video_file = str(video_file or "").strip()
sources = [
name for name, present in (
("video_id", bool(video_id)),
("video_url", bool(video_url)),
("video_file", bool(video_file)),
("video_input", video_input is not None),
("video_frames", video_frames is not None),
)
if present
]
if not sources:
raise ValueError(
"Provide video_id, video_url, video_file, video_input, or video_frames for lip sync."
)
if len(sources) > 1:
raise ValueError(
"Provide only one of video_id, video_url, video_file, video_input, or video_frames for lip sync."
)
if video_id:
return video_id, ""
if video_input is not None:
return "", _upload_video_reference(video_input)
if video_frames is not None:
return "", _upload_reference_video_frames(video_frames, video_info)
if video_file:
if _is_http_url(video_file):
return "", video_file
return "", _upload_file_to_temporary_media_host(video_file)
if _is_http_url(video_url):
return "", video_url
return "", _upload_file_to_temporary_media_host(video_url)
class ImageGeneratorNode:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"model": (IMAGE_GENERATION_MODELS,),
"prompt": ("STRING", {"multiline": True, "default": ""}),
},
"optional": {
"negative_prompt": ("STRING", {"multiline": True, "default": ""}),
"image": ("IMAGE",),
"image_reference": (["None", "subject", "face"],),
"image_fidelity": ("FLOAT", {
"default": 0.5,
"min": 0.0,
"max": 1.0,
"step": 0.1,
"round": 0.01,
"display": "number",
"lazy": True
}),
"human_fidelity": ("FLOAT", {
"default": 0.45,
"min": 0.0,
"max": 1.0,
"step": 0.1,
"round": 0.01,
"display": "number",
"lazy": True
}),
"image_num": ("INT", {
"default": 1,
"min": 0,
"max": 9,
"step": 1,
"display": "number",
"lazy": True
}),
"aspect_ratio": (EXTENDED_IMAGE_ASPECT_RATIOS,),
"resolution": (DEFAULT_IMAGE_RESOLUTIONS,),
}
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "generate"
OUTPUT_NODE = False
CATEGORY = NODE_CATEGORY
def generate(self,
model,
prompt,
negative_prompt=None,
image=None,
image_reference="None",
image_fidelity=None,
human_fidelity=None,
resolution=None,
image_num=None,
aspect_ratio=None):
_reject_vapeur_unsupported("image generation")
has_input_image = image is not None
capability = validate_image_generation_request(
model_name=model,
has_input_image=has_input_image,
aspect_ratio=aspect_ratio,
resolution=resolution,
image_reference_mode=image_reference,
image_fidelity=image_fidelity,
human_fidelity=human_fidelity,
)
generator = ImageGenerator()
generator.model_name = model
_apply_prompt_controls(generator, capability, prompt=prompt, negative_prompt=negative_prompt)
_set_if_present(generator, "resolution", resolution)
_set_if_present(generator, "aspect_ratio", aspect_ratio)
_set_if_present(generator, "n", image_num)
if has_input_image:
generator.image = _image_to_base64(image)
if image_reference != 'None':
generator.image_reference = image_reference
if image_reference != 'None' and image_fidelity is not None:
generator.image_fidelity = image_fidelity
if image_reference != 'None' and human_fidelity is not None:
generator.human_fidelity = human_fidelity
try:
with _runtime_client() as client:
response = generator.run(client)
except KLingAPIError as exc:
_raise_with_image_model_guidance(exc, model)
_log_final_unit_deduction(response, "image_generation")
imgs = None
for image_info in response.task_result.images:
image_url = _preferred_media_url(image_info)
img = _images2tensor(_decode_image(_fetch_image(image_url)))
if imgs is None:
imgs = img
else:
imgs = torch.cat([imgs, img], dim=0)
print(f'KLing API output: {image_url}')
return (imgs,)
class ImageExpanderNode:
# TODO ?
@classmethod
def INPUT_TYPES(s):
expansion_ratio_parameter = {
"default": 0,
"min": 0,
"max": 2,
}
return {
"required": {
"image": ("IMAGE",),
"up_expansion_ratio": ("FLOAT", expansion_ratio_parameter),
"down_expansion_ratio": ("FLOAT", expansion_ratio_parameter),
"left_expansion_ratio": ("FLOAT", expansion_ratio_parameter),
"right_expansion_ratio": ("FLOAT", expansion_ratio_parameter),
},
"optional": {
"prompt": ("STRING", {"multiline": True, "default": ""}),
"image_num": ("INT", {
"default": 1,
"min": 0,
"max": 9,
"step": 1,
"display": "number",
"lazy": True
}),
}
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "generate"
OUTPUT_NODE = False
CATEGORY = NODE_CATEGORY
def generate(self,
image,
prompt=None,
image_num=None,
up_expansion_ratio=None,
down_expansion_ratio=None,
left_expansion_ratio=None,
right_expansion_ratio=None,
):
_reject_vapeur_unsupported("image expansion")
generator = ImageExpander()
generator.image = _image_to_base64(image)
generator.up_expansion_ratio = up_expansion_ratio
generator.down_expansion_ratio = down_expansion_ratio
generator.left_expansion_ratio = left_expansion_ratio
generator.right_expansion_ratio = right_expansion_ratio
generator.prompt = prompt
generator.n = image_num
with _runtime_client() as client:
response = generator.run(client)
imgs = None
for image_info in response.task_result.images:
img = _images2tensor(_decode_image(_fetch_image(image_info.url)))
if imgs is None:
imgs = img
else:
imgs = torch.cat([imgs, img], dim=0)
print(f'KLing API output: {image_info.url}')
return (imgs,)
class Image2VideoNode:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"model": (IMAGE_TO_VIDEO_MODELS,),
},
"optional": {
"image": ("IMAGE",),
"image_tail": ("IMAGE",),
"prompt": ("STRING", {"multiline": True, "default": ""}),
"negative_prompt": ("STRING", {"multiline": True, "default": ""}),
"cfg_scale": ("FLOAT", {
"default": 0.5,
"min": 0.0,
"max": 1.0,
"step": 0.1,
"round": 0.01,
"display": "number",
"lazy": True
}),
"mode": (VIDEO_GENERATION_MODES,),
"duration": (DEFAULT_VIDEO_DURATIONS,),
"camera_control_type": (CAMERA_CONTROL_TYPES,),
"camera_control_config": (CAMERA_CONTROL_CONFIGS,),
"camera_control_value": ("FLOAT", {
"default": 0.5,
"min": -10.0,
"max": 10.0,
"step": 1.0,
"round": 1.0,
"display": "number",
"lazy": True
}),
"sound": (SOUND_OPTIONS,),
"voice_preset": (VOICE_PRESET_OPTIONS, {"default": DEFAULT_VOICE_PRESET}),
"custom_voice_id": ("STRING", {"forceInput": True}),
"custom_voice_list": (CUSTOM_VOICE_LIST_TYPE,),
"shot_type": (SHOT_TYPES,),
"image_list": ("IMAGE",),
"element_list": (ELEMENT_LIST_TYPE,),
"reference_video": ("STRING", {"multiline": False, "default": ""}),
}
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("url", "video_id")
FUNCTION = "generate"
OUTPUT_NODE = False
CATEGORY = NODE_CATEGORY
def generate(self,
model,
provider=None,
image=None,
image_tail=None,
prompt=None,
negative_prompt=None,
cfg_scale=None,
mode=None,
duration=None,
camera_control_type=None,
camera_control_config=None,
camera_control_value=None,
sound="off",
voice_preset=DEFAULT_VOICE_PRESET,
custom_voice_id="",
custom_voice_list=None,
shot_type="single",
image_list=None,
element_list=None,
reference_video=""):
provider = _resolve_video_provider(provider)
capability = get_video_capability(model)
mode = _normalize_requested_mode(model, capability, mode)
normalized_element_list = _normalize_element_list(element_list)
preset_voice_list = _build_voice_list_from_preset(voice_preset)
normalized_custom_voice_list = _normalize_voice_list(custom_voice_list)
custom_voice_id = str(custom_voice_id or "").strip()
if custom_voice_id and preset_voice_list:
raise ValueError("Use custom_voice_id or voice_preset, not both.")
if normalized_custom_voice_list and (custom_voice_id or preset_voice_list):
raise ValueError("Use custom_voice_list, custom_voice_id, or voice_preset, not more than one.")
if custom_voice_id and sound != "on":
raise ValueError("custom_voice_id requires sound='on'.")
if normalized_custom_voice_list and sound != "on":
raise ValueError("custom_voice_list requires sound='on'.")
if custom_voice_id and "<<<voice_1>>>" not in str(prompt or ""):
raise ValueError("custom_voice_id requires the prompt marker <<<voice_1>>>.")
_validate_voice_prompt_markers(prompt, normalized_custom_voice_list)
if custom_voice_id and normalized_element_list and model != "kling-v3-omni":
raise ValueError("custom_voice_id cannot be used together with element_list.")
if normalized_custom_voice_list and normalized_element_list and model != "kling-v3-omni":
raise ValueError("custom_voice_list cannot be used together with element_list.")
parsed_voice_list = (
normalized_custom_voice_list
or ([{"voice_id": custom_voice_id}] if custom_voice_id else None)
or preset_voice_list
)
encoded_image_list = _image_batch_to_base64_list(image_list)
has_reference_video = isinstance(reference_video, str) and bool(reference_video.strip())
has_camera_control = camera_control_type not in (None, "None")
if provider == TENCENT_VOD_PROVIDER and image is None:
raise ValueError("Tencent VOD image2video requires a primary IMAGE input.")
if image is None and not has_reference_video and not encoded_image_list and not normalized_element_list:
raise ValueError("Provide image, image_list, element_list, or reference_video for Image2Video.")
if image_tail is not None and image is None:
raise ValueError("image_tail requires image.")
capability = validate_video_generation_request(
task_name="image2video",
model_name=model,
mode=mode,
duration=duration,
shot_type=shot_type,
has_image_tail=image_tail is not None,
has_image_list=bool(encoded_image_list),
has_element_list=bool(normalized_element_list),
has_reference_video=has_reference_video,
has_sound=sound == "on",
has_voice_list=bool(parsed_voice_list),
has_camera_control=has_camera_control,
element_types=_collect_element_types(normalized_element_list),
)
request = Image2VideoRequest(
model_name=model,
prompt=_clean_optional_text(prompt),
negative_prompt=_clean_optional_text(negative_prompt),
cfg_scale=cfg_scale if capability.get("supports_cfg_scale", True) else None,
mode=mode,
duration=duration,
sound=sound,
voice_list=parsed_voice_list,
shot_type=shot_type,
image_base64=_image_to_base64(image) if image is not None else None,
image_tail_base64=_image_to_base64(image_tail) if image_tail is not None else None,
image_list=encoded_image_list or None,
element_list=normalized_element_list,
reference_video=reference_video.strip() if has_reference_video else None,
camera_control=_build_camera_control(
camera_control_type,
camera_control_config,
camera_control_value,
) if has_camera_control else None,
)
if provider in {TENCENT_VOD_PROVIDER, VAPEUR_PROVIDER}:
request = _build_url_image2video_request(
request,
image,
image_tail,
image_list,
)
try:
with _video_provider(provider) as backend:
result = backend.generate_image2video(request)
except KLingAPIError as exc:
_raise_with_model_guidance(exc, model)
if result.final_unit_deduction is not None:
print(f"[{NODE_PREFIX}] final_unit_deduction | image2video: {result.final_unit_deduction}")
print(f'KLing API output video id: {result.video_id}, url: {result.url}')
return (result.url, result.video_id)
class Text2VideoNode:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"model": (TEXT_TO_VIDEO_MODELS,),
"prompt": ("STRING", {"multiline": True, "default": ""}),
},
"optional": {
"negative_prompt": ("STRING", {"multiline": True, "default": ""}),
"cfg_scale": ("FLOAT", {
"default": 0.5,
"min": 0.0,
"max": 1.0,
"step": 0.1,
"round": 0.01,
"display": "number",
"lazy": True
}),
"mode": (VIDEO_GENERATION_MODES,),
"aspect_ratio": (DEFAULT_VIDEO_ASPECT_RATIOS,),
"duration": (DEFAULT_VIDEO_DURATIONS,),
"camera_control_type": (CAMERA_CONTROL_TYPES,),
"camera_control_config": (CAMERA_CONTROL_CONFIGS,),
"camera_control_value": ("FLOAT", {
"default": 0.5,
"min": -10.0,
"max": 10.0,
"step": 1.0,
"round": 1.0,
"display": "number",
"lazy": True
}),
"sound": (SOUND_OPTIONS,),
"voice_preset": (VOICE_PRESET_OPTIONS, {"default": DEFAULT_VOICE_PRESET}),
"shot_type": (SHOT_TYPES,),
"image_list": ("IMAGE",),
"element_list": (ELEMENT_LIST_TYPE,),
"reference_video": ("STRING", {"multiline": False, "default": ""}),
}
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("url", "video_id")
FUNCTION = "generate"
OUTPUT_NODE = False
CATEGORY = NODE_CATEGORY
def generate(self,
model,
prompt,
provider=None,
negative_prompt=None,
cfg_scale=None,
mode=None,
aspect_ratio=None,
duration=None,
camera_control_type=None,
camera_control_config=None,
camera_control_value=None,
sound="off",
voice_preset=DEFAULT_VOICE_PRESET,
shot_type="single",
image_list=None,
element_list=None,
reference_video=""):
provider = _resolve_video_provider(provider)
capability = get_video_capability(model)
mode = _normalize_requested_mode(model, capability, mode)
normalized_element_list = _normalize_element_list(element_list)
parsed_voice_list = _build_voice_list_from_preset(voice_preset)
encoded_image_list = _image_batch_to_base64_list(image_list)
has_reference_video = isinstance(reference_video, str) and bool(reference_video.strip())
has_camera_control = camera_control_type not in (None, "None")
capability = validate_video_generation_request(
task_name="text2video",
model_name=model,
mode=mode,
duration=duration,
shot_type=shot_type,
has_image_tail=False,
has_image_list=bool(encoded_image_list),
has_element_list=bool(normalized_element_list),
has_reference_video=has_reference_video,
has_sound=sound == "on",
has_voice_list=bool(parsed_voice_list),
has_camera_control=has_camera_control,
element_types=_collect_element_types(normalized_element_list),
)
request = Text2VideoRequest(
model_name=model,
prompt=_clean_optional_text(prompt) or "",
negative_prompt=_clean_optional_text(negative_prompt),
cfg_scale=cfg_scale if capability.get("supports_cfg_scale", True) else None,
mode=mode,
aspect_ratio=aspect_ratio,
duration=duration,
sound=sound,
voice_list=parsed_voice_list,
shot_type=shot_type,
image_list=encoded_image_list or None,
element_list=normalized_element_list,
reference_video=reference_video.strip() if has_reference_video else None,
camera_control=_build_camera_control(
camera_control_type,
camera_control_config,
camera_control_value,
) if has_camera_control else None,
)
if provider == TENCENT_VOD_PROVIDER:
request = _build_tencent_vod_text2video_request(request)
try:
with _video_provider(provider) as backend:
result = backend.generate_text2video(request)
except KLingAPIError as exc:
_raise_with_model_guidance(exc, model)
if result.final_unit_deduction is not None:
print(f"[{NODE_PREFIX}] final_unit_deduction | text2video: {result.final_unit_deduction}")
print(f'KLing API output video id: {result.video_id}, url: {result.url}')
return (result.url, result.video_id)
class KolorsVirtualTryOnNode:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"model_name": (["kolors-virtual-try-on-v1", "kolors-virtual-try-on-v1-5"],),
"human_image": ("IMAGE",),
"cloth_image": ("IMAGE",),
},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "generate"
OUTPUT_NODE = False
CATEGORY = NODE_CATEGORY
def generate(self,
model_name,
human_image,
cloth_image=None):
_reject_vapeur_unsupported("virtual try-on")
generator = KolorsVurtualTryOn()
generator.model_name = model_name
generator.human_image = _image_to_base64(human_image)
generator.cloth_image = _image_to_base64(cloth_image)
with _runtime_client() as client:
response = generator.run(client)
for image_info in response.task_result.images:
img = _images2tensor(_decode_image(_fetch_image(image_info.url)))
print(f'KLing API output: {image_info.url}')
return (img,)
class ClothingDupeNode:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"product_info": ("STRING", {"multiline": True, "default": ""}),
"source_video": ("VIDEO",),
"product_image_1": ("IMAGE",),
"resolution": (["720p", "1080p"], {"default": "720p"}),
"aspect_ratio": (CLOTHING_DUPE_ASPECT_RATIOS, {"default": "auto"}),
"voice_id": (CLOTHING_DUPE_VOICE_OPTIONS, {"default": "无"}),
},
"optional": {
"product_image_2": ("IMAGE",),
"product_image_3": ("IMAGE",),
"product_image_4": ("IMAGE",),
"product_image_5": ("IMAGE",),
"model_image_1": ("IMAGE",),
"model_image_2": ("IMAGE",),
"model_image_3": ("IMAGE",),
"model_image_4": ("IMAGE",),
"model_image_5": ("IMAGE",),
"bgm": ("AUDIO",),
},
}
RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING")
RETURN_NAMES = ("video_url", "watermark_video_url", "task_id", "result_json")
FUNCTION = "generate"
OUTPUT_NODE = False
CATEGORY = NODE_CATEGORY
def generate(
self,
product_info,
source_video,
product_image_1,
resolution,
aspect_ratio,
voice_id,
product_image_2=None,
product_image_3=None,
product_image_4=None,
product_image_5=None,
model_image_1=None,
model_image_2=None,
model_image_3=None,
model_image_4=None,
model_image_5=None,
bgm=None,
watermark_enabled=False,
callback_url="",
external_task_id="",
):
product_info = str(product_info or "").strip()
if not product_info:
raise ValueError("product_info is required.")
if len(product_info) > 2500:
raise ValueError("product_info must be 2500 characters or fewer.")
if resolution not in {"720p", "1080p"}:
raise ValueError("resolution must be 720p or 1080p.")
if aspect_ratio not in CLOTHING_DUPE_ASPECT_RATIOS:
raise ValueError("aspect_ratio is not supported by Clothing Dupe.")
_validate_clothing_dupe_video(source_video)
product_image_inputs = [
product_image_1,
product_image_2,
product_image_3,
product_image_4,
product_image_5,
]
model_image_inputs = [
model_image_1,
model_image_2,
model_image_3,
model_image_4,
model_image_5,
]
_validate_clothing_dupe_image_inputs(product_image_inputs, "product_images", required=True)
_validate_clothing_dupe_image_inputs(model_image_inputs, "model_images", required=False)
voice_value = str(voice_id or "无").strip() or "无"
if voice_value in CLOTHING_DUPE_VOICE_ID_BY_LABEL:
resolved_voice_id = CLOTHING_DUPE_VOICE_ID_BY_LABEL[voice_value]
elif voice_value in CLOTHING_DUPE_VOICE_IDS:
resolved_voice_id = voice_value
else:
raise ValueError("voice_id must be one of the official Clothing Dupe voice names.")
contents = [
{"type": "product_info", "text": product_info},
{"type": "source_video", "url": _upload_video_reference(source_video)},
]
for product_image in product_image_inputs:
if product_image is None:
continue
contents.extend(
{"type": "product_image", "url": url}
for url in _upload_image_batch_references(product_image)
)
for model_image in model_image_inputs:
if model_image is None:
continue
contents.extend(
{"type": "model_image", "url": url}
for url in _upload_image_batch_references(model_image)
)
if bgm is not None:
contents.append({"type": "bgm", "url": _upload_audio_reference(bgm)})
if resolved_voice_id:
contents.append({"type": "voice", "voice_id": resolved_voice_id})
settings = {"resolution": resolution}
if aspect_ratio != "auto":
settings["aspect_ratio"] = aspect_ratio
options = {"watermark_info": {"enabled": bool(watermark_enabled)}}
callback_url = str(callback_url or "").strip()
external_task_id = str(external_task_id or "").strip()
if callback_url:
options["callback_url"] = callback_url
if external_task_id:
options["external_task_id"] = external_task_id
payload = {"contents": contents, "settings": settings, "options": options}
with _runtime_client() as client:
result = submit_clothing_dupe(client, payload)
return (
result.video_url,
result.watermark_video_url,
result.task_id,
json.dumps(result.raw, ensure_ascii=False),
)
class PreviewVideo:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"video_url": ("STRING", {"forceInput": True}),
}
}
OUTPUT_NODE = True
FUNCTION = "run"
CATEGORY = NODE_CATEGORY
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("file_path",)
def run(self, video_url, filename_prefix=DEFAULT_VIDEO_FILENAME_PREFIX, save_output=True):
if not save_output:
return {"ui": {"video_url": [video_url]}, "result": ('',)}
output_dir = folder_paths.get_output_directory()
(
full_output_folder,
filename,
counter,
subfolder,
_,
) = folder_paths.get_save_image_path(filename_prefix, output_dir)
file = f"{filename}_{counter:05}_.mp4"
file_path = os.path.join(full_output_folder, file)
preview_url = _build_local_media_view_url(file, subfolder, "output")
if type(video_url) == list:
video_url = video_url[0]
video_url = str(video_url or "").strip()
if not video_url:
raise ValueError(
"PreviewVideo received an empty video_url. "
"The upstream node finished without a downloadable video URL."
)
with open(file_path, "wb") as handle:
handle.write(_fetch_image(video_url))
try:
_register_output_asset(file_path)
except Exception:
pass
return {
"ui": {
"images": [_saved_result(file, subfolder, "output")],
"video_url": [preview_url],
"animated": (True,),
},
"result": (file_path,),
}
class PreviewAudio(LoadAudio):
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"audio_url": ("STRING", {
"forceInput": True,
"default": ""
}),
"filename_prefix": ("STRING", {
"default": DEFAULT_FILENAME_PREFIX
}),
"save_output": ("BOOLEAN", {
"default": True
}),
}
}
RETURN_TYPES = ("AUDIO", "STRING")
RETURN_NAMES = ("audio", "file_path")
FUNCTION = "run"
CATEGORY = NODE_CATEGORY
OUTPUT_NODE = True
@classmethod
def VALIDATE_INPUTS(cls, **kwargs):
return True
@classmethod
def fingerprint_inputs(cls, audio_url=None, filename_prefix=None, save_output=None, **kwargs):
return hashlib.sha256(
json.dumps(
{
"audio_url": audio_url,
"filename_prefix": filename_prefix,
"save_output": save_output,
},
sort_keys=True,
).encode("utf-8")
).hexdigest()
def run(self, audio_url, filename_prefix, save_output):
try:
if not save_output:
return {
"ui": {"audio_url": [audio_url]},
"result": (None, '')
}
output_directory = folder_paths.get_output_directory()
(
full_output_folder,
filename,
counter,
subfolder,
_,
) = folder_paths.get_save_image_path(filename_prefix, output_directory)
saved_file_path = _load_audio_from_url(
audio_url=audio_url,
save_directory=full_output_folder,
filename_prefix=f"{filename}_{counter:05}"
)
audio_result = super().load(saved_file_path)
audio_data = audio_result[0]
return {
"ui": {
"audio": [_saved_result(Path(saved_file_path).name, subfolder, "output")]
},
"result": (audio_data, saved_file_path)
}
except Exception as e:
error_msg = f"[PreviewAudio] Error: {str(e)}"
print(error_msg)
raise Exception(error_msg)
class VideoExtendNode:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"video_id": ("STRING", {"multiline": False, "default": ""}),
"prompt": ("STRING", {"multiline": True, "default": ""}),
}
}
OUTPUT_NODE = True
FUNCTION = "run"
CATEGORY = NODE_CATEGORY
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("url", "video_id")
def run(self, video_id, prompt):
provider = _resolve_video_provider()
if provider == VAPEUR_PROVIDER:
with _video_provider(provider) as backend:
result = backend.generate_video_extend(video_id, prompt)
return (result.url, result.video_id)
if provider == TENCENT_VOD_PROVIDER:
raise ValueError("Tencent VOD provider does not support Video Extend through this node.")
generator = VideoExtend()
generator.video_id = video_id
generator.prompt = prompt
with _runtime_client() as client:
response = generator.run(client)
for video_info in response.task_result.videos:
print(f'KLing API output video id: {video_info.id}, url: {video_info.url}')
return (video_info.url, video_info.id)
return ('', '')
class LipSyncTextInputNode:
audio_types = LIPSYNC_AUDIO_TYPES
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"text": ("STRING", {"multiline": True, "default": ""}),
"voice_id": (list(LipSyncTextInputNode.audio_types.keys()), {"multiline": False, "default": ""}),
"voice_language": (["zh", "en"], {"default": "zh"}),
"voice_speed": ("FLOAT", {
"default": 1.0,
"min": 0.8,
"max": 2.0,
"step": 0.1,
"round": 0.01,
"display": "number",
"lazy": True
})
}
}
OUTPUT_NODE = True
FUNCTION = "run"
CATEGORY = NODE_CATEGORY
RETURN_TYPES = (LIPSYNC_INPUT_TYPE,)
RETURN_NAMES = ("input",)
def run(self, text, voice_id, voice_language, voice_speed):
input = LipSyncInput()
input.mode = "text2video"
input.text = text
if voice_id in LipSyncTextInputNode.audio_types:
input.voice_id = LipSyncTextInputNode.audio_types[voice_id]
else:
input.voice_id = voice_id
input.voice_language = voice_language
input.voice_speed = voice_speed
return (input,)
class LipSyncAudioInputNode:
@classmethod
def INPUT_TYPES(cls):
return {
"optional": {
"audio": ("AUDIO",),
"audio_id": ("STRING", {"multiline": False, "default": ""}),
"audio_file": ("STRING", {"multiline": False, "default": ""}),
"audio_url": ("STRING", {"multiline": False, "default": ""}),
"sound_start_time_ms": ("INT", {
"default": 0,
"min": 0,
"max": 60000,
"step": 100,
}),
"sound_end_time_ms": ("INT", {
"default": 0,
"min": 0,
"max": 60000,
"step": 100,
}),
},
}
OUTPUT_NODE = True
FUNCTION = "run"
CATEGORY = NODE_CATEGORY
RETURN_TYPES = (LIPSYNC_INPUT_TYPE,)
RETURN_NAMES = ("input",)
def run(
self,
audio=None,
audio_id="",
audio_file="",
audio_url="",
sound_start_time_ms=0,
sound_end_time_ms=0,
):
audio_id = str(audio_id or "").strip()
audio_file = str(audio_file or "").strip()
audio_url = str(audio_url or "").strip()
sources = [
name for name, present in (
("audio", audio is not None),
("audio_id", bool(audio_id)),
("audio_file", bool(audio_file)),
("audio_url", bool(audio_url)),
)
if present
]
if not sources:
raise ValueError("Provide audio, audio_id, audio_file, or audio_url for lip sync audio input.")
if len(sources) > 1:
raise ValueError("Provide only one of audio, audio_id, audio_file, or audio_url for lip sync audio input.")
input = LipSyncInput()
input.mode = "audio2video"
if audio is not None:
input.audio_duration_ms = _audio_duration_ms(audio)
input.audio_url = _upload_audio_reference(audio)
elif audio_id:
input.audio_id = audio_id
elif audio_file:
input.audio_url = _upload_file_to_temporary_media_host(audio_file)
else:
if not _is_http_url(audio_url):
raise ValueError("audio_url must be an http(s) URL. Use audio or audio_file for local audio.")
input.audio_url = audio_url
input.sound_start_time = int(sound_start_time_ms)
input.sound_end_time = int(sound_end_time_ms)
return (input,)
class LipSyncNode:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"input": (LIPSYNC_INPUT_TYPE,),
"face_id": ("STRING", {"multiline": False, "default": ""})
},
"optional": {
"video_id": ("STRING", {"multiline": False, "default": ""}),
"video_url": ("STRING", {"multiline": False, "default": ""}),
"video_file": ("STRING", {"multiline": False, "default": ""}),
"video_input": ("VIDEO",),
"video_frames": ("IMAGE",),
"video_info": ("VHS_VIDEOINFO",),
"sound_insert_time_ms": ("INT", {
"default": 0,
"min": 0,
"max": 60000,
"step": 100,
}),
"sound_volume": ("FLOAT", {
"default": 1.0,
"min": 0.0,
"max": 2.0,
"step": 0.1,
}),
"original_audio_volume": ("FLOAT", {
"default": 1.0,
"min": 0.0,
"max": 2.0,
"step": 0.1,
}),
}
}
OUTPUT_NODE = True
FUNCTION = "run"
CATEGORY = NODE_CATEGORY
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("url", "video_id")
def run(
self,
input,
face_id,
video_id=None,
video_url=None,
video_file="",
video_input=None,
video_frames=None,
video_info=None,
sound_insert_time_ms=0,
sound_volume=1.0,
original_audio_volume=1.0,
):
_reject_vapeur_unsupported("lip sync")
video_id, video_url = _resolve_lip_sync_video_reference(
video_id=video_id,
video_url=video_url,
video_file=video_file,
video_input=video_input,
video_frames=video_frames,
video_info=video_info,
)
with _runtime_client() as client:
identify = FaceIdentify()
if video_id:
identify.video_id = video_id
else:
identify.video_url = video_url
identify_response = identify.run(client)
_log_final_unit_deduction(identify_response, "identify_face")
faces = list(getattr(identify_response, "face_data", None) or [])
if not faces:
raise ValueError("Kling face identification did not return any usable faces.")
if not str(getattr(identify_response, "session_id", "") or "").strip():
raise ValueError("Kling face identification returned an empty session_id.")
requested_face_id = str(face_id or "").strip()
selected_face = faces[0]
if requested_face_id:
selected_face = next(
(face for face in faces if str(getattr(face, "face_id", "")) == requested_face_id),
None,
)
if selected_face is None:
available = ", ".join(str(getattr(face, "face_id", "")) for face in faces)
raise ValueError(
f"face_id={requested_face_id!r} was not returned by identify-face. "
f"Available face IDs: {available or 'none'}."
)
audio_id = str(getattr(input, "audio_id", "") or "").strip()
sound_file = str(getattr(input, "audio_url", "") or "").strip()
audio_duration_ms = int(getattr(input, "audio_duration_ms", 0) or 0)
if getattr(input, "mode", "") == "text2video":
tts = TTS()
tts.text = input.text
tts.voice_id = input.voice_id
tts.voice_language = _normalize_tts_voice_language(input.text, input.voice_language)
tts.voice_speed = float(input.voice_speed)
tts_response = tts.run(client)
_log_final_unit_deduction(tts_response, "lip_sync_tts")
audio_payload = _extract_tts_audio_result(tts_response)
audio_id = str(audio_payload.get("id", "") or "").strip()
if not audio_id:
raise ValueError("Kling TTS completed but returned an empty audio_id for lip sync.")
try:
audio_duration_ms = int(round(float(audio_payload.get("duration")) * 1000))
except (TypeError, ValueError) as exc:
raise ValueError("Kling TTS returned an invalid audio duration for lip sync.") from exc
sound_start_time = int(getattr(input, "sound_start_time", 0) or 0)
sound_end_time = int(getattr(input, "sound_end_time", 0) or 0)
if sound_end_time == 0 and audio_duration_ms:
sound_end_time = audio_duration_ms
if sound_end_time <= sound_start_time:
raise ValueError(
"sound_end_time_ms must be greater than sound_start_time_ms. "
"For audio_id, audio_file, or audio_url inputs, provide sound_end_time_ms explicitly."
)
clip_duration_ms = sound_end_time - sound_start_time
if not 2000 <= clip_duration_ms <= 60000:
raise ValueError("The trimmed lip-sync audio must be between 2 and 60 seconds.")
sound_insert_time = int(sound_insert_time_ms)
face_start_time = getattr(selected_face, "start_time", None)
face_end_time = getattr(selected_face, "end_time", None)
if face_start_time is not None and face_end_time is not None:
inserted_end_time = sound_insert_time + clip_duration_ms
overlap_ms = max(
0,
min(int(face_end_time), inserted_end_time) - max(int(face_start_time), sound_insert_time),
)
if overlap_ms < 2000:
raise ValueError(
"The inserted audio must overlap the selected face's visible interval by at least 2 seconds."
)
face_choose = {
"face_id": str(getattr(selected_face, "face_id", "")),
"sound_start_time": sound_start_time,
"sound_end_time": sound_end_time,
"sound_insert_time": sound_insert_time,
"sound_volume": float(sound_volume),
"original_audio_volume": float(original_audio_volume),
}
if audio_id:
face_choose["audio_id"] = audio_id
elif sound_file:
face_choose["sound_file"] = sound_file
else:
raise ValueError("Lip sync requires audio_id or an uploaded audio file URL.")
generator = AdvancedLipSync()
generator.session_id = identify_response.session_id
generator.face_choose = [face_choose]
response = generator.run(client)
_log_final_unit_deduction(response, "advanced_lip_sync")
video_url, video_id = _extract_first_video_result(response, "lip_sync")
print(f'KLing API output video id: {video_id}, url: {video_url}')
return (video_url, video_id)
class AvatarNode:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"mode": (["std", "pro"], {"default": "std"}),
},
"optional": {
"audio": ("AUDIO",),
"audio_id": ("STRING", {"multiline": False, "default": ""}),
"audio_file": ("STRING", {"multiline": False, "default": ""}),
"audio_url": ("STRING", {"multiline": False, "default": ""}),
"prompt": ("STRING", {"multiline": True, "default": ""}),
},
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("url", "video_id")
FUNCTION = "generate"
OUTPUT_NODE = True
CATEGORY = NODE_CATEGORY
def generate(
self,
image,
mode,
audio=None,
audio_id="",
audio_file="",
audio_url="",
prompt="",
):
_reject_vapeur_unsupported("avatar generation")
audio_id = str(audio_id or "").strip()
audio_file = str(audio_file or "").strip()
audio_url = str(audio_url or "").strip()
sources = [
name for name, present in (
("audio", audio is not None),
("audio_id", bool(audio_id)),
("audio_file", bool(audio_file)),
("audio_url", bool(audio_url)),
)
if present
]
if not sources:
raise ValueError("Provide audio, audio_id, audio_file, or audio_url for Avatar.")
if len(sources) > 1:
raise ValueError("Provide only one of audio, audio_id, audio_file, or audio_url for Avatar.")
generator = Avatar()
generator.image = _image_to_base64(image)
generator.mode = mode
if prompt:
generator.prompt = str(prompt)
if audio is not None:
duration_ms = _audio_duration_ms(audio)
if not 2000 <= duration_ms <= 300000:
raise ValueError("Avatar audio must be between 2 and 300 seconds.")
generator.sound_file = _upload_audio_reference(audio)
elif audio_id:
generator.audio_id = audio_id
elif audio_file:
generator.sound_file = _upload_file_to_temporary_media_host(audio_file)
else:
if not _is_http_url(audio_url):
raise ValueError("audio_url must be an http(s) URL. Use audio or audio_file for local audio.")
generator.sound_file = audio_url
with _runtime_client() as client:
response = generator.run(client)
_log_final_unit_deduction(response, "avatar")
return _extract_first_video_result(response, "avatar")
class EffectNode:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"effect_scene": (
["baseball", "inner_voice", "a_list_look", "memory_alive", "trampoline", "trampoline_night",
"pucker_up", "guess_what", "feed_mooncake", "rampage_ape", "flyer", "dishwasher",
"pet_chinese_opera", "magic_fireball", "gallery_ring", "pet_moto_rider", "muscle_pet",
"squeeze_scream",
"pet_delivery", "running_man", "disappear", "mythic_style", "steampunk", "c4d_cartoon",
"3d_cartoon_1",
"3d_cartoon_2", "eagle_snatch", "hug_from_past", "firework", 'media_interview', "pet_lion",
"pet_chef",
"santa_gifts", "santa_hug", "girlfriend", "boyfriend", "heart_gesture_1", "pet_wizard",
"smoke_smoke", "thumbs_up",
"instant_kid", "dollar_rain", "cry_cry", "building_collapse", "gun_shot", "mushroom", "double_gun",
"pet_warrior",
"lightning_power", "jesus_hug", "shark_alert", "long_hair", "lie_flat", "polar_bear_hug",
"brown_bear_hug",
"jazz_jazz", "office_escape_plow", "fly_fly", "watermelon_bomb", "pet_dance", "boss_coming",
"wool_curly",
"pet_bee", "marry_me", "swing_swing", "day_to_night", "piggy_morph", "wig_out", "car_explosion",
"ski_ski",
"tiger_hug", "siblings", "construction_worker", "let’s_ride", "snatched", "magic_broom",
"felt_felt", "jumpdrop", "celebration", "splashsplash", "surfsurf", "fairy_wing", "angel_wing",
"dark_wing", "skateskate", "plushcut", "jelly_press", "jelly_slice", "jelly_squish",
"jelly_jiggle",
"pixelpixel", "yearbook", "instant_film", "anime_figure", "rocketrocket", "bloombloom",
"dizzydizzy", "fuzzyfuzzy",
"squish", "expansion", "hug", "kiss", "heart_gesture", "fight"
],),
"model_name": (["kling-v1", "kling-v1-5", "kling-v1-6"],),
"mode": (["std", "pro"],),
"duration": (["5", "10"],),
"image0": ("IMAGE",),
},
"optional": {
"image1": ("IMAGE",)
}
}
OUTPUT_NODE = True
FUNCTION = "run"
CATEGORY = NODE_CATEGORY
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("url", "video_id")
def run(self, effect_scene, model_name, mode, duration, image0, image1=None):
_reject_vapeur_unsupported("video effects")
generator = Effects()
generator.effect_scene = effect_scene
generator.input = EffectInput()
if effect_scene in ["hug", "kiss", "heart_gesture", "fight"]:
generator.input.model_name = 'kling-v1-6' if effect_scene == 'fight' else model_name
generator.input.mode = mode
generator.input.duration = duration
if image1 == None or image0 == None:
raise Exception("This effect needs two images.")
generator.input.images = [_image_to_base64(image0), _image_to_base64(image1)]
else:
generator.input.mode = mode
generator.input.duration = duration
if image1 != None and image0 != None:
raise Exception("This effect needs one image.")
generator.input.image = _image_to_base64(image0) if image1 == None else _image_to_base64(image1)
with _runtime_client() as client:
response = generator.run(client)
for video_info in response.task_result.videos:
print(f'KLing API output video id: {video_info.id}, url: {video_info.url}')
return (video_info.url, video_info.id)
return ('', '')
class Video2AudioNode:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"video_id": ("STRING", {"multiline": False, "default": ""}),
"video_url": ("STRING", {"multiline": False, "default": ""}),
},
"optional": {
"sound_effect_prompt": ("STRING", {"multiline": True, "default": ""}),
"bgm_prompt": ("STRING", {"multiline": True, "default": ""}),
"asmr_mode": ("BOOLEAN", {"multiline": False, "default": False}),
}
}
RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING")
RETURN_NAMES = ("videos_id", "videos_url", "audio_id", "audio_url_mp3")
FUNCTION = "generate"
OUTPUT_NODE = False
CATEGORY = NODE_CATEGORY
def generate(self,
video_id,
video_url,
sound_effect_prompt=None,
bgm_prompt=None,
asmr_mode=False,
):
_reject_vapeur_unsupported("video-to-audio")
generator = Video2Audio()
generator.video_id = video_id
generator.video_url = video_url
if not video_id and not video_url:
raise Exception("Please input video_id or video_url")
if video_id and video_url:
raise Exception("Please input one of video_id or video_url")
generator.sound_effect_prompt = sound_effect_prompt
generator.bgm_prompt = bgm_prompt
generator.asmr_mode = asmr_mode
with _runtime_client() as client:
response = generator.run(client)
audio_info = response.task_result.audios[0]
return (
getattr(audio_info, "video_id", ""),
getattr(audio_info, "video_url", ""),
getattr(audio_info, "audio_id", ""),
getattr(audio_info, "url_mp3", ""),
)
class MultiImagesToVideoNode:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": (MULTI_IMAGE_TO_VIDEO_MODELS,),
"image_list": ("IMAGE",),
},
"optional": {
"image_tail": ("IMAGE",),
"prompt": ("STRING", {"multiline": True, "default": ""}),
"negative_prompt": ("STRING", {"multiline": True, "default": ""}),
"mode": (VIDEO_GENERATION_MODES,),
"duration": (DEFAULT_VIDEO_DURATIONS,),
"aspect_ratio": (EXTENDED_IMAGE_ASPECT_RATIOS[:-1],),
}
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("url", "video_id")
FUNCTION = "generate"
OUTPUT_NODE = False
CATEGORY = NODE_CATEGORY
def generate(
self,
model,
image_list,
provider=None,
image_tail=None,
prompt="",
negative_prompt="",
mode="std",
duration="5",
aspect_ratio="16:9",
):
provider = _resolve_video_provider(provider)
capability = get_video_capability(model)
mode = _normalize_requested_mode(model, capability, mode)
encoded_image_list = _image_batch_to_base64_list(image_list)
if not encoded_image_list:
raise ValueError("image_list must contain at least one image.")
if len(encoded_image_list) < 2:
raise ValueError("MultiImagesToVideo requires at least two images.")
capability = validate_video_generation_request(
task_name="multi_image2video",
model_name=model,
mode=mode,
duration=duration,
shot_type="single",
has_image_tail=image_tail is not None,
has_image_list=True,
has_element_list=False,
has_reference_video=False,
has_sound=False,
has_voice_list=False,
has_camera_control=False,
)
if provider == TENCENT_VOD_PROVIDER:
image_urls = _upload_image_batch_references(image_list)
tail_urls = _upload_image_batch_references(image_tail) if image_tail is not None else []
request = MultiImagesToVideoRequest(
model_name=model,
prompt=_clean_optional_text(prompt) or "",
negative_prompt=_clean_optional_text(negative_prompt),
mode=mode,
duration=duration,
aspect_ratio=aspect_ratio,
image_urls=image_urls,
image_tail_url=tail_urls[0] if tail_urls else None,
)
with _video_provider(provider) as backend:
result = backend.generate_multi_images(request)
print(f'Tencent VOD output video id: {result.video_id}, url: {result.url}')
return (result.url, result.video_id)
if provider == VAPEUR_PROVIDER:
image_urls = _upload_image_batch_references(image_list)
tail_urls = _upload_image_batch_references(image_tail) if image_tail is not None else []
request = MultiImagesToVideoRequest(
model_name=model,
prompt=_clean_optional_text(prompt) or "",
negative_prompt=_clean_optional_text(negative_prompt),
mode=mode,
duration=duration,
aspect_ratio=aspect_ratio,
image_urls=image_urls,
image_tail_url=tail_urls[0] if tail_urls else None,
)
with _video_provider(provider) as backend:
result = backend.generate_multi_images(request)
return (result.url, result.video_id)
generator = MultiImages2Video()
generator.model_name = model
generator.image = encoded_image_list[0]
generator.image_list = encoded_image_list[1:]
_apply_prompt_controls(generator, capability, prompt=prompt, negative_prompt=negative_prompt)
_set_if_present(generator, "mode", mode)
_set_if_present(generator, "duration", duration)
_set_if_present(generator, "aspect_ratio", aspect_ratio)
if image_tail is not None:
generator.image_tail = _image_to_base64(image_tail)
try:
with _runtime_client() as client:
response = generator.run(client)
except KLingAPIError as exc:
_raise_with_model_guidance(exc, model)
_log_final_unit_deduction(response, "multi_image2video")
return _extract_first_video_result(response, "multi_image2video")
class MultiModalVideoEditNode:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": (["kling-v3-omni"], {"default": "kling-v3-omni"}),
"prompt": ("STRING", {"multiline": True, "default": ""}),
},
"optional": {
"reference_video": ("STRING", {"multiline": False, "default": ""}),
"reference_video_input": ("VIDEO",),
"reference_video_frames": ("IMAGE",),
"reference_video_info": ("VHS_VIDEOINFO",),
"image_list": ("IMAGE",),
"negative_prompt": ("STRING", {"multiline": True, "default": ""}),
"edit_mode": (["base", "feature"], {"default": "base"}),
"keep_original_sound": ("BOOLEAN", {"default": False}),
"mode": (DEFAULT_MODES,),
"duration": (DEFAULT_VIDEO_DURATIONS, {"default": "3"}),
"aspect_ratio": (DEFAULT_VIDEO_ASPECT_RATIOS,),
"extra_payload_json": ("STRING", {"multiline": True, "default": ""}),
},
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("url", "video_id")
FUNCTION = "generate"
OUTPUT_NODE = False
CATEGORY = NODE_CATEGORY
def generate(
self,
model,
prompt,
provider=None,
reference_video="",
reference_video_input=None,
reference_video_frames=None,
reference_video_info=None,
image_list=None,
negative_prompt="",
edit_mode="base",
keep_original_sound=False,
mode="std",
duration="3",
aspect_ratio="16:9",
extra_payload_json="",
):
provider = _resolve_video_provider(provider)
reference_video = _resolve_motion_control_reference_video(
reference_video,
reference_video_input=reference_video_input,
reference_video_frames=reference_video_frames,
reference_video_info=reference_video_info,
)
if not reference_video:
raise ValueError("A reference video URL or VIDEO input is required for multimodal editing.")
extra_payload = _parse_json_input(extra_payload_json, "extra_payload_json")
if extra_payload and not isinstance(extra_payload, dict):
raise ValueError("extra_payload_json must decode to a JSON object.")
if provider == VAPEUR_PROVIDER:
raise ValueError(
"Vapeur provider does not support MultiModal Video Edit because the node requires kling-v3-omni."
)
if provider == TENCENT_VOD_PROVIDER:
request = MultiModalVideoEditRequest(
model_name=model,
video_url=reference_video,
prompt=_clean_optional_text(prompt) or "",
negative_prompt=_clean_optional_text(negative_prompt),
edit_mode=edit_mode,
keep_original_sound=bool(keep_original_sound),
mode=mode,
duration=duration,
aspect_ratio=aspect_ratio,
image_urls=_upload_image_batch_references(image_list) if image_list is not None else [],
extra_payload=extra_payload or None,
)
with _video_provider(provider) as backend:
result = backend.generate_multimodal_edit(request)
return (result.url, result.video_id)
generator = MultiModelVideoEdit()
generator.model_name = model
generator.prompt = _clean_optional_text(prompt) or ""
generator.negative_prompt = _clean_optional_text(negative_prompt) or ""
generator.edit_mode = edit_mode
generator.mode = mode
generator.duration = duration
generator.video_list = [{
"video_url": reference_video,
"keep_original_sound": "yes" if keep_original_sound else "no",
}]
encoded_images = _image_batch_to_base64_list(image_list)
if encoded_images:
generator.image_list = encoded_images
for key, value in (extra_payload or {}).items():
setattr(generator, key, value)
with _runtime_client() as client:
response = generator.run(client)
_log_final_unit_deduction(response, "multimodal_video_edit")
return _extract_first_video_result(response, "multimodal_video_edit")
class AdvancedCustomElementCreateNode:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"element_type": (["image_subject", "multi_image_subject", "video_character"],),
"element_name": ("STRING", {"multiline": False, "default": ""}),
"element_description": ("STRING", {"multiline": True, "default": ""}),
},
"optional": {
"image": ("IMAGE",),
"image_list": ("IMAGE",),
"video_url": ("STRING", {"multiline": False, "default": ""}),
"element_voice_id": ("STRING", {"multiline": False, "default": ""}),
"extra_payload_json": ("STRING", {"multiline": True, "default": ""}),
}
}
RETURN_TYPES = (ELEMENT_TYPE, "STRING", "STRING")
RETURN_NAMES = ("element", "element_id", "element_json")
FUNCTION = "create"
OUTPUT_NODE = False
CATEGORY = NODE_CATEGORY
def create(
self,
element_type,
element_name="",
element_description="",
provider=None,
image=None,
image_list=None,
video_url="",
element_voice_id="",
extra_payload_json="",
):
provider = _resolve_video_provider(provider)
generator = AdvancedCustomElements()
element_name = (element_name or "").strip()
element_description = (element_description or "").strip()
if element_type in ("image_subject", "multi_image_subject"):
generator.reference_type = "image_refer"
else: # video_character
generator.reference_type = "video_refer"
merged_image_list = []
if image is not None:
merged_image_list.extend(_upload_image_batch_references(image))
if image_list is not None:
merged_image_list.extend(_upload_image_batch_references(image_list))
if element_type in ("image_subject", "multi_image_subject"):
# Kling's advanced element API requires one frontal image plus 1-3
# additional reference images of the same subject. Background-only
# or scene images do not satisfy this constraint.
if len(merged_image_list) < 2:
raise ValueError(
"Advanced custom elements require 1 frontal portrait in `image` plus 1-3 additional "
"reference photos of the same subject in `image_list`. "
"Background or scene images do not count as subject reference images."
)
if len(merged_image_list) > 4:
raise ValueError(
"Advanced custom elements support at most 4 subject images total: "
"1 frontal image plus 1-3 additional reference images."
)
if element_type == "image_subject":
generator.element_image_list = {
"frontal_image": merged_image_list[0],
"refer_images": [{"image_url": image_b64} for image_b64 in merged_image_list[1:]],
}
elif element_type == "multi_image_subject":
generator.element_image_list = {
"frontal_image": merged_image_list[0],
"refer_images": [{"image_url": image_b64} for image_b64 in merged_image_list[1:]],
}
elif element_type == "video_character":
if not video_url.strip():
raise ValueError("video_character requires video_url.")
generator.element_video_list = {
"refer_videos": [{"video_url": video_url.strip()}],
}
if element_voice_id.strip():
generator.element_voice_id = element_voice_id.strip()
extra_payload = _parse_json_input(extra_payload_json, "extra_payload_json")
if extra_payload:
if not isinstance(extra_payload, dict):
raise ValueError("extra_payload_json must decode to a JSON object.")
for key, value in extra_payload.items():
setattr(generator, key, value)
if not element_description:
element_description = str(
getattr(generator, "element_description", "")
or ""
).strip()
if not element_description:
raise ValueError("element_description is required for advanced element creation.")
if len(element_description) > 100:
raise ValueError(
"element_description must be 100 characters or fewer for advanced element creation. "
"This describes the reusable subject itself and is unrelated to the Image2Video voice mode."
)
if not element_name:
element_name = str(
getattr(generator, "element_name", "")
or ""
).strip()
if not element_name:
raise ValueError("element_name is required for advanced element creation.")
if len(element_name) > 20:
raise ValueError("element_name must be 20 characters or fewer for advanced element creation.")
generator.element_name = element_name
generator.element_description = element_description
if provider in {TENCENT_VOD_PROVIDER, VAPEUR_PROVIDER}:
request = AdvancedElementCreateRequest(
element_name=element_name,
element_description=element_description,
reference_type=generator.reference_type,
element_image_list=getattr(generator, "element_image_list", None),
element_video_list=getattr(generator, "element_video_list", None),
element_voice_id=str(getattr(generator, "element_voice_id", "") or "").strip() or None,
extra_payload=extra_payload or None,
)
with _video_provider(provider) as backend:
result = backend.create_element(request)
return (
result.element,
result.element_id,
json.dumps(result.element, ensure_ascii=False),
)
with _runtime_client() as client:
response = generator.run(client)
_log_final_unit_deduction(response, "advanced_custom_element_create")
elements = _extract_elements_from_response(response)
if not elements:
payload = {"task_id": getattr(response, "task_id", None)}
return (
payload,
"",
json.dumps(payload, ensure_ascii=False),
)
element_payload = _element_result_to_payload(elements[0])
element_id = element_payload.get("element_id") or element_payload.get("id") or ""
return (
element_payload,
element_id,
json.dumps(element_payload, ensure_ascii=False),
)
class AdvancedCustomElementQueryNode:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"query_mode": (["task_id", "element_id"],),
"identifier": ("STRING", {"multiline": False, "default": ""}),
},
}
RETURN_TYPES = (ELEMENT_TYPE, "STRING", "STRING")
RETURN_NAMES = ("element", "element_id", "element_json")
FUNCTION = "query"
OUTPUT_NODE = False
CATEGORY = NODE_CATEGORY
def query(self, query_mode, identifier, provider=None):
provider = _resolve_video_provider(provider)
identifier = identifier.strip()
if not identifier:
raise ValueError("identifier is required.")
if provider in {TENCENT_VOD_PROVIDER, VAPEUR_PROVIDER}:
with _video_provider(provider) as backend:
result = backend.query_element(query_mode, identifier)
return (
result.element,
result.element_id,
json.dumps(result.element, ensure_ascii=False),
)
candidate_paths = []
if query_mode == "task_id":
candidate_paths.append(f"/v1/general/advanced-custom-elements/{identifier}")
else:
candidate_paths.extend([
f"/v1/general/advanced-custom-elements/elements/{identifier}",
f"/v1/general/advanced-custom-elements/{identifier}",
])
last_error = None
response = None
with _runtime_client() as client:
for path in candidate_paths:
try:
response = client.request("GET", path)
break
except Exception as exc:
last_error = exc
if response is None:
raise last_error
data = response.get("data", {})
elements = _extract_elements_from_response(data)
if not elements and query_mode == "element_id":
elements = [data]
if not elements:
payload = data if isinstance(data, dict) else {"identifier": identifier}
return (
payload,
payload.get("element_id", identifier) if isinstance(payload, dict) else identifier,
json.dumps(payload, ensure_ascii=False),
)
element_payload = _element_result_to_payload(elements[0])
element_id = element_payload.get("element_id") or element_payload.get("id") or identifier
return (
element_payload,
element_id,
json.dumps(element_payload, ensure_ascii=False),
)
class AdvancedCustomElementDeleteNode:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"element_id": ("STRING", {"multiline": False, "default": ""}),
},
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("element_id", "request_id")
FUNCTION = "delete"
OUTPUT_NODE = True
CATEGORY = NODE_CATEGORY
def delete(self, element_id, provider=None):
provider = _resolve_video_provider(provider)
element_id = str(element_id or "").strip()
if not element_id:
raise ValueError("element_id is required.")
if provider == VAPEUR_PROVIDER:
raise ValueError("Vapeur provider does not support subject deletion because the supplied API has no delete endpoint.")
if provider == TENCENT_VOD_PROVIDER:
with _video_provider(provider) as backend:
request_id = backend.delete_element(element_id)
return (element_id, request_id)
candidate_paths = [
f"/v1/general/advanced-custom-elements/elements/{element_id}",
f"/v1/general/advanced-custom-elements/{element_id}",
]
last_error = None
response = None
with _runtime_client() as client:
for path in candidate_paths:
try:
response = client.request("DELETE", path)
break
except Exception as exc:
last_error = exc
if response is None:
raise last_error
request_id = str(response.get("request_id") or response.get("RequestId") or "")
return (element_id, request_id)
class ElementListBuilderNode:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"element_1": (ELEMENT_TYPE,),
},
"optional": {
"element_2": (ELEMENT_TYPE,),
"element_3": (ELEMENT_TYPE,),
"element_4": (ELEMENT_TYPE,),
"extra_element_ids": ("STRING", {"multiline": True, "default": ""}),
}
}
RETURN_TYPES = (ELEMENT_LIST_TYPE, "STRING")
RETURN_NAMES = ("element_list", "element_list_json")
FUNCTION = "build"
OUTPUT_NODE = False
CATEGORY = NODE_CATEGORY
def build(self, element_1, element_2=None, element_3=None, element_4=None, extra_element_ids=""):
element_refs = []
for element in (element_1, element_2, element_3, element_4):
if element is not None:
element_refs.append(_normalize_element_reference(element))
for raw_line in (extra_element_ids or "").replace(",", "\n").splitlines():
identifier = raw_line.strip()
if identifier:
element_refs.append({"element_id": identifier})
normalized = _normalize_element_list(element_refs)
if not normalized:
raise ValueError("At least one element is required.")
return (
normalized,
json.dumps(normalized, ensure_ascii=False),
)
class CustomVoiceListBuilderNode:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"voice_1": ("STRING", {"forceInput": True}),
},
"optional": {
"voice_2": ("STRING", {"forceInput": True}),
"voice_3": ("STRING", {"forceInput": True}),
"voice_4": ("STRING", {"forceInput": True}),
"extra_voice_ids": ("STRING", {"multiline": True, "default": ""}),
}
}
RETURN_TYPES = (CUSTOM_VOICE_LIST_TYPE, "STRING")
RETURN_NAMES = ("custom_voice_list", "custom_voice_list_json")
FUNCTION = "build"
OUTPUT_NODE = False
CATEGORY = NODE_CATEGORY
def build(self, voice_1, voice_2=None, voice_3=None, voice_4=None, extra_voice_ids=""):
voice_refs = []
for voice_id in (voice_1, voice_2, voice_3, voice_4):
if voice_id is not None:
voice_refs.append(voice_id)
for raw_line in (extra_voice_ids or "").replace(",", "\n").splitlines():
voice_id = raw_line.strip()
if voice_id:
voice_refs.append(voice_id)
normalized = _normalize_voice_list(voice_refs)
if not normalized:
raise ValueError("At least one voice_id is required.")
return (
normalized,
json.dumps(normalized, ensure_ascii=False),
)
class MotionControlNode:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model_name": (MOTION_CONTROL_MODELS, {"default": "kling-v2-6"}),
"reference_image": ("IMAGE",),
},
"optional": {
"reference_video": ("STRING", {"multiline": False, "default": ""}),
"reference_video_input": ("VIDEO",),
"reference_video_frames": ("IMAGE",),
"reference_video_info": ("VHS_VIDEOINFO",),
"prompt": ("STRING", {"multiline": True, "default": ""}),
"negative_prompt": ("STRING", {"multiline": True, "default": ""}),
"mode": (DEFAULT_MODES,),
"duration": (MOTION_CONTROL_DURATIONS, {"default": "auto"}),
"character_orientation": (["video", "image"], {"default": "video"}),
"keep_original_sound": ("BOOLEAN", {"default": True}),
"element_list": (ELEMENT_LIST_TYPE,),
"extra_payload_json": ("STRING", {"multiline": True, "default": ""}),
}
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("url", "video_id")
FUNCTION = "generate"
OUTPUT_NODE = False
CATEGORY = NODE_CATEGORY
def generate(
self,
model_name,
reference_image,
provider=None,
reference_video="",
prompt="",
negative_prompt="",
mode="std",
duration="auto",
character_orientation="video",
keep_original_sound=True,
element_list=None,
extra_payload_json="",
reference_video_input=None,
reference_video_frames=None,
reference_video_info=None,
):
provider = _resolve_video_provider(provider)
model_name = (model_name or "kling-v2-6").strip() or "kling-v2-6"
reference_video = _resolve_motion_control_reference_video(
reference_video,
reference_video_input=reference_video_input,
reference_video_frames=reference_video_frames,
reference_video_info=reference_video_info,
)
if not reference_video:
raise ValueError(
"reference_video, reference_video_input, or reference_video_frames is required for motion control."
)
duration = _resolve_motion_control_duration(
duration,
reference_video_input=reference_video_input,
reference_video_frames=reference_video_frames,
reference_video_info=reference_video_info,
)
normalized_element_list = _normalize_element_list(element_list)
extra_payload = _parse_json_input(extra_payload_json, "extra_payload_json")
if extra_payload and not isinstance(extra_payload, dict):
raise ValueError("extra_payload_json must decode to a JSON object.")
image_url = _upload_image_reference(reference_image)
if provider in {TENCENT_VOD_PROVIDER, VAPEUR_PROVIDER}:
request = MotionControlRequest(
model_name=model_name,
image_url=image_url,
video_url=reference_video,
prompt=_clean_optional_text(prompt) or "",
negative_prompt=_clean_optional_text(negative_prompt),
mode=mode,
duration=duration,
character_orientation=character_orientation,
keep_original_sound=bool(keep_original_sound),
element_list=normalized_element_list,
extra_payload=extra_payload or None,
)
with _video_provider(provider) as backend:
result = backend.generate_motion_control(request)
return (result.url, result.video_id)
generator = MotionControl()
generator.model_name = model_name
generator.prompt = prompt
generator.negative_prompt = negative_prompt
generator.mode = mode
generator.duration = duration
generator.image_url = image_url
generator.video_url = reference_video
generator.character_orientation = character_orientation
generator.keep_original_sound = "yes" if keep_original_sound else "no"
if normalized_element_list:
generator.element_list = normalized_element_list
for key, value in (extra_payload or {}).items():
setattr(generator, key, value)
with _runtime_client() as client:
response = generator.run(client)
_log_final_unit_deduction(response, "motion_control")
video_url, video_id = _extract_first_video_result(response, "motion_control")
print(f'KLing API output video id: {video_id}, url: {video_url}')
return (video_url, video_id)
class TextToAudioNode:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"prompt": ("STRING", {"multiline": True, "default": ""}),
"duration": ("FLOAT", {
"default": 3.0,
"min": 3.0,
"max": 10.0,
"step": 0.1,
"display": "number",
},),
},
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("id", "url")
FUNCTION = "generate"
OUTPUT_NODE = True
CATEGORY = NODE_CATEGORY
def generate(self,
prompt,
duration,
):
_reject_vapeur_unsupported("text-to-audio")
generator = Text2Audio()
generator.prompt = prompt
generator.duration = duration
with _runtime_client() as client:
response = generator.run(client)
url_mp3 = getattr(response.task_result.audios[0], 'url_mp3', None)
if not isinstance(url_mp3, str) or not url_mp3.strip():
raise Exception(f"url_mp3 无效,当前值为:{url_mp3}")
audio_id = getattr(response.task_result.audios[0], 'audio_id', None)
print(f"成功提取:audio_id={audio_id}, url_mp3={url_mp3}")
return (audio_id, url_mp3)
class CustomVoiceCreateNode:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"voice_name": ("STRING", {"multiline": False, "default": "Custom Voice"}),
},
"optional": {
"audio": ("AUDIO",),
"voice_file": ("STRING", {"multiline": False, "default": ""}),
"voice_url": ("STRING", {"multiline": False, "default": ""}),
"video_id": ("STRING", {"multiline": False, "default": ""}),
"external_task_id": ("STRING", {"multiline": False, "default": ""}),
"callback_url": ("STRING", {"multiline": False, "default": ""}),
},
}
RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", "STRING")
RETURN_NAMES = ("voice_id", "voice_name", "trial_url", "task_id", "voice_json")
FUNCTION = "create"
OUTPUT_NODE = True
CATEGORY = NODE_CATEGORY
def create(
self,
voice_name,
provider=None,
audio=None,
voice_file="",
voice_url="",
video_id="",
external_task_id="",
callback_url="",
):
provider = _resolve_video_provider(provider)
voice_name = str(voice_name or "").strip()
if not voice_name:
raise ValueError("voice_name is required.")
if len(voice_name) > 20:
raise ValueError("voice_name must be 20 characters or fewer.")
voice_file = str(voice_file or "").strip()
voice_url = str(voice_url or "").strip()
video_id = str(video_id or "").strip()
external_task_id = str(external_task_id or "").strip()
callback_url = str(callback_url or "").strip()
sources = [
name for name, present in (
("audio", audio is not None),
("voice_file", bool(voice_file)),
("voice_url", bool(voice_url)),
("video_id", bool(video_id)),
)
if present
]
if not sources:
raise ValueError("Provide audio, voice_file, voice_url, or video_id for custom voice creation.")
if len(sources) > 1:
raise ValueError("Provide only one of audio, voice_file, voice_url, or video_id for custom voice creation.")
generator = CustomVoiceCreate()
generator.voice_name = voice_name
if audio is not None:
generator.voice_url = _upload_audio_reference(audio)
elif voice_file:
generator.voice_url = _upload_file_to_temporary_media_host(voice_file)
elif voice_url:
if not _is_http_url(voice_url):
raise ValueError("voice_url must be an http(s) URL. Use audio or voice_file for local media.")
generator.voice_url = voice_url
else:
generator.video_id = video_id
if external_task_id:
generator.external_task_id = external_task_id
if callback_url:
generator.callback_url = callback_url
if provider in {TENCENT_VOD_PROVIDER, VAPEUR_PROVIDER}:
if provider == TENCENT_VOD_PROVIDER and (external_task_id or callback_url):
raise ValueError(
"Tencent VOD custom voice does not support external_task_id or callback_url."
)
request = CustomVoiceCreateRequest(
voice_name=voice_name,
voice_url=str(getattr(generator, "voice_url", "") or "").strip() or None,
video_id=str(getattr(generator, "video_id", "") or "").strip() or None,
)
with _video_provider(provider) as backend:
result = backend.create_custom_voice(request)
voice_payload = result.voice
return (
voice_payload.get("voice_id", ""),
voice_payload.get("voice_name", ""),
voice_payload.get("trial_url", ""),
result.task_id,
json.dumps(
{
"task_id": result.task_id,
"request_id": result.request_id,
"voice": voice_payload,
},
ensure_ascii=False,
),
)
with _runtime_client() as client:
response = generator.run(client)
_log_final_unit_deduction(response, "custom_voice_create")
voice_payload = _extract_custom_voice_result(response)
task_id = getattr(response, "task_id", "") or ""
result_json = json.dumps(
{
"task_id": task_id,
"task_status": getattr(response, "task_status", None),
"task_status_msg": getattr(response, "task_status_msg", None),
"voice": voice_payload,
},
ensure_ascii=False,
)
return (
voice_payload.get("voice_id", ""),
voice_payload.get("voice_name", ""),
voice_payload.get("trial_url", ""),
task_id,
result_json,
)
class CustomVoiceQueryNode:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"task_id_or_external_task_id": ("STRING", {"multiline": False, "default": ""}),
},
}
RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", "STRING")
RETURN_NAMES = ("voice_id", "voice_name", "trial_url", "task_id", "voice_json")
FUNCTION = "query"
OUTPUT_NODE = True
CATEGORY = NODE_CATEGORY
def query(self, task_id_or_external_task_id, provider=None):
provider = _resolve_video_provider(provider)
identifier = str(task_id_or_external_task_id or "").strip()
if not identifier:
raise ValueError("task_id_or_external_task_id is required.")
if provider in {TENCENT_VOD_PROVIDER, VAPEUR_PROVIDER}:
with _video_provider(provider) as backend:
result = backend.query_custom_voice(identifier)
voice_payload = result.voice
return (
voice_payload.get("voice_id", ""),
voice_payload.get("voice_name", ""),
voice_payload.get("trial_url", ""),
result.task_id,
json.dumps(
{
"task_id": result.task_id,
"request_id": result.request_id,
"voice": voice_payload,
},
ensure_ascii=False,
),
)
generator = CustomVoiceQuery(identifier)
with _runtime_client() as client:
response = generator.run(client)
_log_final_unit_deduction(response, "custom_voice_query")
voice_payload = _extract_custom_voice_result(response)
task_id = getattr(response, "task_id", "") or ""
result_json = json.dumps(
{
"task_id": task_id,
"task_status": getattr(response, "task_status", None),
"task_status_msg": getattr(response, "task_status_msg", None),
"voice": voice_payload,
},
ensure_ascii=False,
)
return (
voice_payload.get("voice_id", ""),
voice_payload.get("voice_name", ""),
voice_payload.get("trial_url", ""),
task_id,
result_json,
)
class TTSNode:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"text": ("STRING", {"multiline": True, "default": ""}),
"voice_id": ("STRING", {"multiline": False, "default": ""}),
"voice_language": (["zh", "en"], {"default": "zh"}),
"voice_speed": ("FLOAT", {
"default": 1.0,
"min": 0.8,
"max": 2.0,
"step": 0.1,
"round": 0.1,
"display": "number",
}),
},
}
RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING")
RETURN_NAMES = ("audio_id", "audio_url", "duration", "audio_json")
FUNCTION = "generate"
OUTPUT_NODE = True
CATEGORY = NODE_CATEGORY
def generate(self, text, voice_id, voice_language, voice_speed):
_reject_vapeur_unsupported("text-to-speech")
text = str(text or "").strip()
voice_id = str(voice_id or "").strip()
if not text:
raise ValueError("text is required.")
if len(text) > 1000:
raise ValueError("text must be 1000 characters or fewer.")
if not voice_id:
raise ValueError("voice_id is required.")
generator = TTS()
generator.text = text
generator.voice_id = voice_id
normalized_voice_language = _normalize_tts_voice_language(text, voice_language)
generator.voice_language = normalized_voice_language
generator.voice_speed = float(voice_speed)
try:
with _runtime_client() as client:
response = generator.run(client)
except KLingAPIError as exc:
if str(getattr(exc, "code", "")) == "1201" and "Voice id not found" in str(getattr(exc, "message", "")):
exc.message = (
f"{exc.message}. Check that voice_language matches the selected voice/text language "
f"(current voice_language={normalized_voice_language!r}). If this voice_id came from "
"Custom Voice Create, Kling's /v1/audio/tts endpoint may not accept it; connect "
"Custom Voice Create.trial_url to Preview Audio to audition the cloned voice, or use "
"an official/preset voice_id for TTS."
)
raise
_log_final_unit_deduction(response, "tts")
audio_payload = _extract_tts_audio_result(response)
result_json = json.dumps(
{
"task_id": getattr(response, "task_id", None),
"task_status": getattr(response, "task_status", None),
"task_status_msg": getattr(response, "task_status_msg", None),
"audio": audio_payload,
},
ensure_ascii=False,
)
return (
audio_payload.get("id", ""),
audio_payload.get("url", ""),
str(audio_payload.get("duration", "") or ""),
result_json,
)