2447 lines
83 KiB
Python
2447 lines
83 KiB
Python
from .api import Client, ImageGenerator, ImageExpander, Image2Video, Video2Audio, Text2Audio, \
|
||
Text2Video, CameraControl, CameraControlConfig, KolorsVurtualTryOn, VideoExtend, LipSync, LipSyncInput, EffectInput, \
|
||
Effects, MultiImages2Video, MultiModelVideoEdit, AdvancedCustomElements, MotionControl
|
||
from .api.capabilities import 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, get_image_capability, get_video_capability, \
|
||
validate_image_generation_request, validate_video_generation_request
|
||
from .api.exceptions import KLingAPIError
|
||
import base64
|
||
import configparser
|
||
from contextlib import contextmanager
|
||
import io
|
||
import json
|
||
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
|
||
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_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"
|
||
TMPFILES_MAX_SIZE_BYTES = 100 * 1024 * 1024
|
||
TMPFILES_UPLOAD_RETRY_COUNT = 3
|
||
TMPFILES_UPLOAD_RETRY_DELAY = 1.0
|
||
DEFAULT_REFERENCE_VIDEO_FPS = 24.0
|
||
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") 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_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()
|
||
client = Client(
|
||
_resolve_access_key(config_data),
|
||
_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),
|
||
)
|
||
return client
|
||
|
||
|
||
@contextmanager
|
||
def _runtime_client():
|
||
client = _create_runtime_client()
|
||
try:
|
||
yield client
|
||
finally:
|
||
client.close()
|
||
|
||
|
||
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 _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 _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 _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 _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 _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 _upload_file_to_tmpfiles(file_path, timeout=DEFAULT_UPLOAD_TIMEOUT):
|
||
normalized_path = os.path.abspath(os.fspath(file_path))
|
||
if not os.path.exists(normalized_path):
|
||
raise ValueError(f"Upload file does not exist: {normalized_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_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_tmpfiles(temp_path)
|
||
finally:
|
||
if os.path.exists(temp_path):
|
||
os.remove(temp_path)
|
||
|
||
|
||
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_tmpfiles(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."
|
||
)
|
||
|
||
|
||
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_tmpfiles(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():
|
||
raise ValueError(
|
||
"Direct reference_video URLs are no longer supported for motion control. "
|
||
"Use reference_video_input or reference_video_frames instead."
|
||
)
|
||
|
||
return ""
|
||
|
||
|
||
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):
|
||
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,
|
||
):
|
||
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": (DEFAULT_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}),
|
||
"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,
|
||
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,
|
||
shot_type="single",
|
||
image_list=None,
|
||
element_list=None,
|
||
reference_video=""):
|
||
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")
|
||
|
||
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),
|
||
)
|
||
|
||
generator = Image2Video()
|
||
generator.model_name = model
|
||
_apply_prompt_controls(generator, capability, prompt=prompt, negative_prompt=negative_prompt, cfg_scale=cfg_scale)
|
||
_set_if_present(generator, "mode", mode)
|
||
_set_if_present(generator, "duration", duration)
|
||
|
||
if image is not None:
|
||
generator.image = _image_to_base64(image)
|
||
|
||
if image_tail is not None:
|
||
generator.image_tail = _image_to_base64(image_tail)
|
||
|
||
if has_camera_control:
|
||
generator.camera_control = _build_camera_control(
|
||
camera_control_type,
|
||
camera_control_config,
|
||
camera_control_value,
|
||
)
|
||
|
||
if sound == "on":
|
||
generator.sound = sound
|
||
|
||
if parsed_voice_list:
|
||
generator.voice_list = parsed_voice_list
|
||
|
||
if shot_type != "single":
|
||
generator.shot_type = shot_type
|
||
|
||
if encoded_image_list:
|
||
generator.image_list = encoded_image_list
|
||
|
||
if normalized_element_list:
|
||
generator.element_list = normalized_element_list
|
||
|
||
if has_reference_video:
|
||
generator.reference_video = reference_video.strip()
|
||
|
||
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, "image2video")
|
||
|
||
for video_info in response.task_result.videos:
|
||
video_url = _preferred_media_url(video_info)
|
||
print(f'KLing API output video id: {video_info.id}, url: {video_url}')
|
||
return (video_url, video_info.id)
|
||
|
||
return ('', '')
|
||
|
||
|
||
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": (DEFAULT_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,
|
||
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=""):
|
||
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),
|
||
)
|
||
|
||
generator = Text2Video()
|
||
generator.model_name = model
|
||
_apply_prompt_controls(generator, capability, prompt=prompt, negative_prompt=negative_prompt, cfg_scale=cfg_scale)
|
||
_set_if_present(generator, "mode", mode)
|
||
_set_if_present(generator, "aspect_ratio", aspect_ratio)
|
||
_set_if_present(generator, "duration", duration)
|
||
|
||
if has_camera_control:
|
||
generator.camera_control = _build_camera_control(
|
||
camera_control_type,
|
||
camera_control_config,
|
||
camera_control_value,
|
||
)
|
||
|
||
if sound == "on":
|
||
generator.sound = sound
|
||
|
||
if parsed_voice_list:
|
||
generator.voice_list = parsed_voice_list
|
||
|
||
if shot_type != "single":
|
||
generator.shot_type = shot_type
|
||
|
||
if encoded_image_list:
|
||
generator.image_list = encoded_image_list
|
||
|
||
if normalized_element_list:
|
||
generator.element_list = normalized_element_list
|
||
|
||
if has_reference_video:
|
||
generator.reference_video = reference_video.strip()
|
||
|
||
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, "text2video")
|
||
|
||
for video_info in response.task_result.videos:
|
||
video_url = _preferred_media_url(video_info)
|
||
print(f'KLing API output video id: {video_info.id}, url: {video_url}')
|
||
return (video_url, video_info.id)
|
||
|
||
return ('', '')
|
||
|
||
|
||
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):
|
||
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 PreviewVideo:
|
||
@classmethod
|
||
def INPUT_TYPES(cls):
|
||
return {
|
||
"required": {
|
||
"video_url": ("STRING", {"forceInput": True}),
|
||
"filename_prefix": ("STRING", {"default": DEFAULT_FILENAME_PREFIX}),
|
||
}
|
||
}
|
||
|
||
OUTPUT_NODE = True
|
||
FUNCTION = "run"
|
||
CATEGORY = NODE_CATEGORY
|
||
|
||
RETURN_TYPES = ("STRING",)
|
||
RETURN_NAMES = ("file_path",)
|
||
|
||
def run(self, video_url, 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
|
||
|
||
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):
|
||
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_file": ("STRING", {"multiline": False, "default": ""}),
|
||
"audio_url": ("STRING", {"multiline": False, "default": ""}),
|
||
},
|
||
}
|
||
|
||
OUTPUT_NODE = True
|
||
FUNCTION = "run"
|
||
CATEGORY = NODE_CATEGORY
|
||
|
||
RETURN_TYPES = (LIPSYNC_INPUT_TYPE,)
|
||
RETURN_NAMES = ("input",)
|
||
|
||
def run(self, audio_file, audio_url):
|
||
input = LipSyncInput()
|
||
input.mode = "audio2video"
|
||
if audio_file is not None and len(audio_file) > 0:
|
||
input.audio_type = "file"
|
||
if os.path.exists(audio_file):
|
||
with open(audio_file, 'rb') as file:
|
||
file_data = file.read()
|
||
input.audio_file = base64.b64encode(file_data).decode('utf-8')
|
||
else:
|
||
raise Exception(f"Audio file not found: {audio_file}")
|
||
|
||
if audio_url is not None and len(audio_url) > 0:
|
||
input.audio_type = "url"
|
||
input.audio_url = audio_url
|
||
|
||
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": ""}),
|
||
}
|
||
}
|
||
|
||
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):
|
||
if not video_id and not video_url:
|
||
raise Exception("Please input video_id or video_url.")
|
||
|
||
generator = LipSync()
|
||
input.video_id = video_id
|
||
input.video_url = video_url
|
||
if face_id:
|
||
input.face_id = face_id
|
||
generator.input = input
|
||
|
||
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 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):
|
||
|
||
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,
|
||
):
|
||
|
||
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": (DEFAULT_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,
|
||
image_tail=None,
|
||
prompt="",
|
||
negative_prompt="",
|
||
mode="std",
|
||
duration="5",
|
||
aspect_ratio="16:9",
|
||
):
|
||
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,
|
||
)
|
||
|
||
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")
|
||
|
||
for video_info in response.task_result.videos:
|
||
video_url = _preferred_media_url(video_info)
|
||
print(f'KLing API output video id: {video_info.id}, url: {video_url}')
|
||
return (video_url, video_info.id)
|
||
|
||
return ("", "")
|
||
|
||
|
||
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="",
|
||
image=None,
|
||
image_list=None,
|
||
video_url="",
|
||
element_voice_id="",
|
||
extra_payload_json="",
|
||
):
|
||
generator = AdvancedCustomElements()
|
||
element_name = (element_name or "").strip()
|
||
element_description = (element_description or "").strip()
|
||
|
||
generator.type = element_type
|
||
generator.element_type = element_type
|
||
generator.elementType = element_type
|
||
|
||
# Keep the newer reference-style payload too for accounts using the upgraded schema.
|
||
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(_image_batch_to_base64_list(image))
|
||
if image_list is not None:
|
||
merged_image_list.extend(_image_batch_to_base64_list(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":
|
||
element_image_list_payload = {
|
||
"frontal_image": merged_image_list[0],
|
||
"refer_images": [{"image_url": image_b64} for image_b64 in merged_image_list[1:]],
|
||
}
|
||
generator.image = merged_image_list[0]
|
||
generator.image_list = merged_image_list
|
||
generator.imageList = merged_image_list
|
||
generator.element_image_list = element_image_list_payload
|
||
generator.elementImageList = json.dumps(element_image_list_payload, ensure_ascii=False)
|
||
generator.frontal_image = merged_image_list[0]
|
||
generator.refer_images = merged_image_list[1:]
|
||
elif element_type == "multi_image_subject":
|
||
element_image_list_payload = {
|
||
"frontal_image": merged_image_list[0],
|
||
"refer_images": [{"image_url": image_b64} for image_b64 in merged_image_list[1:]],
|
||
}
|
||
generator.image = merged_image_list[0]
|
||
generator.image_list = merged_image_list
|
||
generator.imageList = merged_image_list
|
||
generator.element_image_list = element_image_list_payload
|
||
generator.elementImageList = json.dumps(element_image_list_payload, ensure_ascii=False)
|
||
generator.frontal_image = merged_image_list[0]
|
||
generator.refer_images = merged_image_list[1:]
|
||
elif element_type == "video_character":
|
||
if not video_url.strip():
|
||
raise ValueError("video_character requires video_url.")
|
||
element_video_list_payload = {
|
||
"refer_videos": [{"video_url": video_url.strip()}],
|
||
}
|
||
generator.video_url = video_url.strip()
|
||
generator.videoUrl = video_url.strip()
|
||
generator.element_video_list = [video_url.strip()]
|
||
generator.elementVideoList = json.dumps(element_video_list_payload, ensure_ascii=False)
|
||
|
||
if element_voice_id.strip():
|
||
generator.element_voice_id = element_voice_id.strip()
|
||
generator.elementVoiceId = element_voice_id.strip()
|
||
generator.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, "description", "")
|
||
or getattr(generator, "element_description", "")
|
||
or getattr(generator, "elementDescription", "")
|
||
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, "name", "")
|
||
or getattr(generator, "element_name", "")
|
||
or getattr(generator, "elementName", "")
|
||
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.name = element_name
|
||
generator.element_name = element_name
|
||
generator.elementName = element_name
|
||
generator.description = element_description
|
||
generator.element_description = element_description
|
||
generator.elementDescription = element_description
|
||
|
||
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):
|
||
identifier = identifier.strip()
|
||
if not identifier:
|
||
raise ValueError("identifier is required.")
|
||
|
||
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 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 MotionControlNode:
|
||
@classmethod
|
||
def INPUT_TYPES(cls):
|
||
return {
|
||
"required": {
|
||
"model_name": (MOTION_CONTROL_MODELS, {"default": "kling-v2-6"}),
|
||
"reference_image": ("IMAGE",),
|
||
},
|
||
"optional": {
|
||
"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,
|
||
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,
|
||
):
|
||
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_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.")
|
||
|
||
generator = MotionControl()
|
||
generator.model_name = model_name
|
||
generator.prompt = prompt
|
||
generator.negative_prompt = negative_prompt
|
||
generator.mode = mode
|
||
generator.duration = duration
|
||
generator.image_url = _upload_image_reference(reference_image)
|
||
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,
|
||
):
|
||
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)
|