4260 lines
153 KiB
Python
4260 lines
153 KiB
Python
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,
|
||
)
|