Files
magicwang1111-Comfyui-Kling…/tests/test_backend_config.py
T

2714 lines
116 KiB
Python

import importlib
import json
import os
import sys
import tempfile
import unittest
import urllib.parse
from contextlib import contextmanager
from pathlib import Path
from types import ModuleType, SimpleNamespace
from unittest import mock
import httpx
import requests
REPO_ROOT = Path(__file__).resolve().parents[1]
COMFYUI_ROOT = REPO_ROOT.parent.parent
if str(COMFYUI_ROOT) not in sys.path:
sys.path.insert(0, str(COMFYUI_ROOT))
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
kling_package = importlib.import_module("py")
kling_nodes = importlib.import_module("py.nodes")
kling_capabilities = importlib.import_module("py.api.capabilities")
kling_client = importlib.import_module("py.api.client")
kling_providers = importlib.import_module("py.api.providers")
kling_official_provider = importlib.import_module("py.api.providers.official")
kling_tencent_provider = importlib.import_module("py.api.providers.tencent_vod")
OFFICIAL_PROVIDER = kling_providers.OFFICIAL_PROVIDER
TENCENT_VOD_PROVIDER = kling_providers.TENCENT_VOD_PROVIDER
OfficialVideoConfig = kling_providers.OfficialVideoConfig
TencentVodVideoConfig = kling_providers.TencentVodVideoConfig
OfficialVideoProvider = kling_official_provider.OfficialVideoProvider
TencentVodVideoProvider = kling_tencent_provider.TencentVodVideoProvider
ENV_KEYS = {
"KLINGAI_API_KEY": "",
"KLING_API_KEY": "",
"KLINGAI_API_ACCESS_KEY": "",
"KLING_ACCESS_KEY": "",
"KLINGAI_API_SECRET_KEY": "",
"KLING_SECRET_KEY": "",
"KLINGAI_AREA": "",
"KLINGAI_POLL_INTERVAL": "",
"KLINGAI_REQUEST_TIMEOUT": "",
"TENCENT_SECRET_ID": "",
"TENCENT_SECRET_KEY": "",
"TENCENTCLOUD_SECRET_ID": "",
"TENCENTCLOUD_SECRET_KEY": "",
"TENCENT_REGION": "",
"TENCENT_SUB_APP_ID": "",
"TENCENT_POLL_INTERVAL": "",
"TENCENT_REQUEST_TIMEOUT": "",
"OSS_ENDPOINT": "",
"OSS_ACCESS_KEY_ID": "",
"OSS_ACCESS_KEY_SECRET": "",
"OSS_BUCKET": "",
"OSS_PREFIX": "",
"OSS_SIGNED_URL_EXPIRES": "",
}
class BackendConfigTests(unittest.TestCase):
def setUp(self):
# Unit tests must not inherit the developer machine's active provider
# or credentials from config.local.json.
config_patch = mock.patch.object(
kling_nodes,
"CONFIG_JSON_PATH",
REPO_ROOT / "tests" / "__missing_config.local.json",
)
config_patch.start()
self.addCleanup(config_patch.stop)
def test_node_mapping_hides_client_node(self):
self.assertNotIn(
"Comfyui-Kling-Wrapper Client",
kling_package.NODE_CLASS_MAPPINGS,
)
self.assertNotIn("client", kling_nodes.ImageGeneratorNode.INPUT_TYPES()["required"])
def test_video_generation_nodes_expose_4k_mode(self):
self.assertIn("4k", kling_nodes.Text2VideoNode.INPUT_TYPES()["optional"]["mode"][0])
self.assertIn("4k", kling_nodes.Image2VideoNode.INPUT_TYPES()["optional"]["mode"][0])
self.assertIn("4k", kling_nodes.MultiImagesToVideoNode.INPUT_TYPES()["optional"]["mode"][0])
self.assertNotIn("4k", kling_nodes.MotionControlNode.INPUT_TYPES()["optional"]["mode"][0])
def test_provider_is_hidden_from_all_node_inputs(self):
for node_class in (
kling_nodes.Image2VideoNode,
kling_nodes.Text2VideoNode,
kling_nodes.MultiImagesToVideoNode,
kling_nodes.MultiModalVideoEditNode,
kling_nodes.AdvancedCustomElementCreateNode,
kling_nodes.AdvancedCustomElementQueryNode,
kling_nodes.AdvancedCustomElementDeleteNode,
kling_nodes.MotionControlNode,
kling_nodes.CustomVoiceCreateNode,
kling_nodes.CustomVoiceQueryNode,
):
input_types = node_class.INPUT_TYPES()
self.assertNotIn("provider", input_types.get("required", {}))
self.assertNotIn("provider", input_types.get("optional", {}))
def test_kling_v3_accepts_4k_video_mode(self):
capability = kling_capabilities.validate_video_generation_request(
task_name="text2video",
model_name="kling-v3",
mode="4k",
duration="3",
)
self.assertIn("4k", capability["modes"])
def test_kling_v3_accepts_native_audio(self):
capability = kling_capabilities.validate_video_generation_request(
task_name="image2video",
model_name="kling-v3",
mode="pro",
duration="3",
has_sound=True,
)
self.assertTrue(capability["supports_sound"])
def test_kling_v3_image2video_sends_selected_sound_value(self):
captured_requests = []
class FakeProvider:
def generate_image2video(self, request):
captured_requests.append(request)
return SimpleNamespace(
url="https://example.com/video.mp4",
video_id="video-123",
final_unit_deduction=None,
)
def close(self):
pass
with mock.patch.object(kling_nodes, "_create_video_provider", return_value=FakeProvider()):
for sound in ("on", "off"):
with self.subTest(sound=sound):
url, video_id = kling_nodes.Image2VideoNode().generate(
model="kling-v3",
element_list=[{"element_id": "element-123"}],
mode="pro",
duration="3",
sound=sound,
)
self.assertEqual(url, "https://example.com/video.mp4")
self.assertEqual(video_id, "video-123")
self.assertEqual([request.sound for request in captured_requests], ["on", "off"])
def test_image2video_custom_voice_id_is_connectable(self):
custom_voice_input = kling_nodes.Image2VideoNode.INPUT_TYPES()["optional"]["custom_voice_id"]
self.assertTrue(custom_voice_input[1]["forceInput"])
def test_non_4k_models_reject_4k_video_mode(self):
with self.assertRaisesRegex(ValueError, "kling-v2-6 only supports modes: pro"):
kling_capabilities.validate_video_generation_request(
task_name="text2video",
model_name="kling-v2-6",
mode="4k",
duration="5",
)
def test_kling_v3_turbo_is_visible_with_legacy_mode_names(self):
self.assertIn("kling-v3-turbo", kling_capabilities.TEXT_TO_VIDEO_MODELS)
self.assertIn("kling-v3-turbo", kling_capabilities.IMAGE_TO_VIDEO_MODELS)
capability = kling_capabilities.validate_video_generation_request(
task_name="text2video",
model_name="kling-v3-turbo",
mode="pro",
duration="7",
)
self.assertEqual(capability["modes"], ["std", "pro", "4k"])
self.assertIn("14", capability["durations"])
self.assertFalse(capability["supports_sound"])
def test_kling_v3_turbo_rejects_old_unsupported_controls(self):
with self.assertRaisesRegex(ValueError, "does not support sound control"):
kling_capabilities.validate_video_generation_request(
task_name="image2video",
model_name="kling-v3-turbo",
mode="std",
duration="5",
has_sound=True,
)
with self.assertRaisesRegex(ValueError, "does not support image_list"):
kling_capabilities.validate_video_generation_request(
task_name="image2video",
model_name="kling-v3-turbo",
mode="std",
duration="5",
has_image_list=True,
)
with self.assertRaisesRegex(ValueError, "does not support image_tail"):
kling_capabilities.validate_video_generation_request(
task_name="image2video",
model_name="kling-v3-turbo",
mode="std",
duration="5",
has_image_tail=True,
)
def test_omni_reference_video_rejects_4k_mode(self):
with self.assertRaisesRegex(ValueError, "does not support mode=4k with reference_video"):
kling_capabilities.validate_video_generation_request(
task_name="image2video",
model_name="kling-v3-omni",
mode="4k",
duration="5",
has_reference_video=True,
)
def test_lip_sync_nodes_expose_direct_audio_and_video_inputs(self):
audio_inputs = kling_nodes.LipSyncAudioInputNode.INPUT_TYPES()["optional"]
video_inputs = kling_nodes.LipSyncNode.INPUT_TYPES()["optional"]
self.assertEqual(audio_inputs["audio"][0], "AUDIO")
self.assertEqual(video_inputs["video_input"][0], "VIDEO")
self.assertEqual(video_inputs["video_frames"][0], "IMAGE")
self.assertEqual(video_inputs["video_info"][0], "VHS_VIDEOINFO")
def test_lip_sync_audio_input_accepts_comfy_audio(self):
fake_audio = {"waveform": SimpleNamespace(shape=(1, 1, 132300)), "sample_rate": 44100}
with mock.patch.object(kling_nodes, "_upload_audio_reference", return_value="https://example.com/audio.wav") as upload_mock:
lip_sync_input, = kling_nodes.LipSyncAudioInputNode().run(audio=fake_audio)
upload_mock.assert_called_once_with(fake_audio)
self.assertEqual(lip_sync_input.mode, "audio2video")
self.assertEqual(lip_sync_input.audio_url, "https://example.com/audio.wav")
self.assertEqual(lip_sync_input.audio_duration_ms, 3000)
def test_lip_sync_audio_input_uploads_audio_file(self):
with mock.patch.object(
kling_nodes,
"_upload_file_to_temporary_media_host",
return_value="https://example.com/local-audio.wav",
) as upload_mock:
lip_sync_input, = kling_nodes.LipSyncAudioInputNode().run(audio_file="D:/input/local-audio.wav")
upload_mock.assert_called_once_with("D:/input/local-audio.wav")
self.assertEqual(lip_sync_input.audio_url, "https://example.com/local-audio.wav")
def test_lip_sync_audio_input_rejects_multiple_sources(self):
with self.assertRaisesRegex(ValueError, "Provide only one"):
kling_nodes.LipSyncAudioInputNode().run(
audio={"waveform": object(), "sample_rate": 44100},
audio_url="https://example.com/audio.mp3",
)
def test_custom_voice_create_accepts_comfy_audio(self):
sentinel_client = object()
captured = {}
@contextmanager
def fake_runtime_client():
yield sentinel_client
def fake_run(generator, client):
captured["client"] = client
captured["payload"] = generator.to_dict()
return SimpleNamespace(
task_id="task-voice-123",
task_status="succeed",
task_status_msg="",
final_unit_deduction="1.0",
task_result=SimpleNamespace(
voices=[
SimpleNamespace(
voice_id="voice-123",
voice_name="Demo Voice",
trial_url="https://example.com/trial.mp3",
owned_by="creator",
)
]
),
)
fake_audio = {"waveform": object(), "sample_rate": 44100}
with mock.patch.object(kling_nodes, "_runtime_client", fake_runtime_client):
with mock.patch.object(kling_nodes.CustomVoiceCreate, "run", fake_run):
with mock.patch.object(
kling_nodes,
"_upload_audio_reference",
return_value="https://example.com/reference.wav",
) as upload_mock:
voice_id, voice_name, trial_url, task_id, voice_json = (
kling_nodes.CustomVoiceCreateNode().create(
voice_name="Demo Voice",
audio=fake_audio,
external_task_id="external-123",
)
)
upload_mock.assert_called_once_with(fake_audio)
self.assertIs(captured["client"], sentinel_client)
self.assertEqual(
captured["payload"],
{
"external_task_id": "external-123",
"voice_name": "Demo Voice",
"voice_url": "https://example.com/reference.wav",
},
)
self.assertEqual(voice_id, "voice-123")
self.assertEqual(voice_name, "Demo Voice")
self.assertEqual(trial_url, "https://example.com/trial.mp3")
self.assertEqual(task_id, "task-voice-123")
self.assertEqual(json.loads(voice_json)["voice"]["voice_id"], "voice-123")
def test_custom_voice_create_uploads_voice_file(self):
sentinel_client = object()
captured = {}
@contextmanager
def fake_runtime_client():
yield sentinel_client
def fake_run(generator, client):
captured["payload"] = generator.to_dict()
return SimpleNamespace(
task_id="task-file-123",
task_status="succeed",
task_result=SimpleNamespace(
voices=[SimpleNamespace(voice_id="voice-file", voice_name="File Voice", trial_url="")]
),
)
with mock.patch.object(kling_nodes, "_runtime_client", fake_runtime_client):
with mock.patch.object(kling_nodes.CustomVoiceCreate, "run", fake_run):
with mock.patch.object(
kling_nodes,
"_upload_file_to_temporary_media_host",
return_value="https://example.com/local-reference.mp3",
) as upload_mock:
kling_nodes.CustomVoiceCreateNode().create(
voice_name="File Voice",
voice_file="D:/input/reference.mp3",
)
upload_mock.assert_called_once_with("D:/input/reference.mp3")
self.assertEqual(
captured["payload"],
{
"voice_name": "File Voice",
"voice_url": "https://example.com/local-reference.mp3",
},
)
def test_custom_voice_create_rejects_missing_or_multiple_sources(self):
with self.assertRaisesRegex(ValueError, "Provide audio"):
kling_nodes.CustomVoiceCreateNode().create(voice_name="Demo Voice")
with self.assertRaisesRegex(ValueError, "Provide only one"):
kling_nodes.CustomVoiceCreateNode().create(
voice_name="Demo Voice",
voice_url="https://example.com/audio.mp3",
video_id="video-123",
)
def test_custom_voice_create_rejects_long_name(self):
with self.assertRaisesRegex(ValueError, "20 characters"):
kling_nodes.CustomVoiceCreateNode().create(
voice_name="x" * 21,
voice_url="https://example.com/audio.mp3",
)
def test_custom_voice_query_parses_voice_result(self):
sentinel_client = object()
captured = {}
@contextmanager
def fake_runtime_client():
yield sentinel_client
def fake_run(generator, client):
captured["client"] = client
captured["identifier"] = generator.identifier
return SimpleNamespace(
task_id="task-query-123",
task_status="succeed",
task_result=SimpleNamespace(
voices=[
SimpleNamespace(
voice_id="voice-query",
voice_name="Query Voice",
trial_url="https://example.com/query.mp3",
owned_by="creator",
)
]
),
)
with mock.patch.object(kling_nodes, "_runtime_client", fake_runtime_client):
with mock.patch.object(kling_nodes.CustomVoiceQuery, "run", fake_run):
voice_id, voice_name, trial_url, task_id, voice_json = (
kling_nodes.CustomVoiceQueryNode().query("external-123")
)
self.assertIs(captured["client"], sentinel_client)
self.assertEqual(captured["identifier"], "external-123")
self.assertEqual(voice_id, "voice-query")
self.assertEqual(voice_name, "Query Voice")
self.assertEqual(trial_url, "https://example.com/query.mp3")
self.assertEqual(task_id, "task-query-123")
self.assertEqual(json.loads(voice_json)["voice"]["voice_name"], "Query Voice")
def test_tts_node_builds_payload_and_returns_audio_url(self):
sentinel_client = object()
captured = {}
@contextmanager
def fake_runtime_client():
yield sentinel_client
def fake_run(generator, client):
captured["client"] = client
captured["payload"] = generator.to_dict()
return SimpleNamespace(
task_id="task-tts-123",
task_status="succeed",
task_result=SimpleNamespace(
audios=[
SimpleNamespace(
id="audio-123",
url="https://example.com/output.mp3",
duration="3.2",
)
]
),
final_unit_deduction="0.5",
)
with mock.patch.object(kling_nodes, "_runtime_client", fake_runtime_client):
with mock.patch.object(kling_nodes.TTS, "run", fake_run):
audio_id, audio_url, duration, audio_json = kling_nodes.TTSNode().generate(
text="Hello from a cloned voice.",
voice_id="voice-123",
voice_language="en",
voice_speed=1.2,
)
self.assertIs(captured["client"], sentinel_client)
self.assertEqual(
captured["payload"],
{
"text": "Hello from a cloned voice.",
"voice_id": "voice-123",
"voice_language": "en",
"voice_speed": 1.2,
},
)
self.assertEqual(audio_id, "audio-123")
self.assertEqual(audio_url, "https://example.com/output.mp3")
self.assertEqual(duration, "3.2")
self.assertEqual(json.loads(audio_json)["audio"]["url"], "https://example.com/output.mp3")
def test_tts_node_uses_zh_for_chinese_text_even_if_en_selected(self):
captured = {}
@contextmanager
def fake_runtime_client():
yield object()
def fake_run(generator, client):
captured["payload"] = generator.to_dict()
return SimpleNamespace(
task_id="task-tts-zh",
task_status="succeed",
task_result=SimpleNamespace(
audios=[SimpleNamespace(id="audio-zh", url="https://example.com/zh.mp3", duration="2.0")]
),
)
with mock.patch.object(kling_nodes, "_runtime_client", fake_runtime_client):
with mock.patch.object(kling_nodes.TTS, "run", fake_run):
kling_nodes.TTSNode().generate(
text="二十岁有二十岁的迷茫。",
voice_id="voice-zh",
voice_language="en",
voice_speed=1.0,
)
self.assertEqual(captured["payload"]["voice_language"], "zh")
def test_preview_audio_fingerprint_accepts_audio_url_inputs(self):
first = kling_nodes.PreviewAudio.fingerprint_inputs(
audio_url="https://example.com/a.mp3",
filename_prefix="demo",
save_output=True,
)
second = kling_nodes.PreviewAudio.fingerprint_inputs(
audio_url="https://example.com/a.mp3",
filename_prefix="demo",
save_output=True,
)
self.assertEqual(first, second)
self.assertIsInstance(first, str)
def test_custom_voice_and_tts_nodes_are_registered(self):
self.assertIn("Comfyui-Kling-Wrapper Custom Voice Create", kling_package.NODE_CLASS_MAPPINGS)
self.assertIn("Comfyui-Kling-Wrapper Custom Voice Query", kling_package.NODE_CLASS_MAPPINGS)
self.assertIn("Comfyui-Kling-Wrapper TTS", kling_package.NODE_CLASS_MAPPINGS)
self.assertIn("Comfyui-Kling-Wrapper Avatar", kling_package.NODE_CLASS_MAPPINGS)
def test_new_video_api_paths_match_official_endpoints(self):
self.assertEqual(kling_nodes.FaceIdentify()._request_path, "/v1/videos/identify-face")
self.assertEqual(kling_nodes.AdvancedLipSync()._request_path, "/v1/videos/advanced-lip-sync")
self.assertEqual(kling_nodes.Avatar()._request_path, "/v1/videos/avatar/image2video")
def test_lip_sync_uploads_video_frames(self):
sentinel_client = object()
captured = {}
@contextmanager
def fake_runtime_client():
yield sentinel_client
def fake_identify_run(generator, client):
captured["identify_client"] = client
captured["identify_payload"] = generator.to_dict()
return SimpleNamespace(
session_id="session-123",
face_data=[SimpleNamespace(face_id="face-0", start_time=0, end_time=5000)],
)
def fake_advanced_run(generator, client):
captured["advanced_client"] = client
captured["advanced_payload"] = generator.to_dict()
return SimpleNamespace(
task_status="succeed",
task_result=SimpleNamespace(
videos=[SimpleNamespace(url="https://example.com/lipsync.mp4", id="video-123")]
),
)
fake_frames = object()
fake_info = {"loaded_fps": 25.0}
lip_sync_input = SimpleNamespace(
mode="audio2video",
audio_url="https://example.com/audio.wav",
audio_duration_ms=3000,
sound_start_time=0,
sound_end_time=0,
)
with mock.patch.object(kling_nodes, "_runtime_client", fake_runtime_client):
with mock.patch.object(kling_nodes.FaceIdentify, "run", fake_identify_run):
with mock.patch.object(kling_nodes.AdvancedLipSync, "run", fake_advanced_run):
with mock.patch.object(
kling_nodes,
"_upload_reference_video_frames",
return_value="https://tmpfiles.org/dl/123/source.mp4",
) as upload_mock:
url, video_id = kling_nodes.LipSyncNode().run(
lip_sync_input,
face_id="",
video_frames=fake_frames,
video_info=fake_info,
)
upload_mock.assert_called_once_with(fake_frames, fake_info)
self.assertEqual(url, "https://example.com/lipsync.mp4")
self.assertEqual(video_id, "video-123")
self.assertEqual(
captured["identify_payload"],
{"video_url": "https://tmpfiles.org/dl/123/source.mp4"},
)
self.assertEqual(captured["identify_client"], sentinel_client)
self.assertEqual(captured["advanced_client"], sentinel_client)
self.assertEqual(
captured["advanced_payload"],
{
"session_id": "session-123",
"face_choose": [
{
"face_id": "face-0",
"sound_file": "https://example.com/audio.wav",
"sound_start_time": 0,
"sound_end_time": 3000,
"sound_insert_time": 0,
"sound_volume": 1.0,
"original_audio_volume": 1.0,
}
],
},
)
def test_lip_sync_uploads_video_input_object(self):
sentinel_client = object()
captured = {}
@contextmanager
def fake_runtime_client():
yield sentinel_client
def fake_identify_run(generator, client):
captured["identify_payload"] = generator.to_dict()
return SimpleNamespace(
session_id="session-456",
face_data=[
SimpleNamespace(face_id="face-0", start_time=0, end_time=5000),
SimpleNamespace(face_id="face-1", start_time=1000, end_time=6000),
],
)
def fake_advanced_run(generator, client):
captured["advanced_payload"] = generator.to_dict()
return SimpleNamespace(
task_status="succeed",
task_result=SimpleNamespace(
videos=[SimpleNamespace(url="https://example.com/lipsync.mp4", id="video-456")]
),
)
fake_video = object()
lip_sync_input = SimpleNamespace(
mode="audio2video",
audio_id="audio-123",
sound_start_time=0,
sound_end_time=3000,
)
with mock.patch.object(kling_nodes, "_runtime_client", fake_runtime_client):
with mock.patch.object(kling_nodes.FaceIdentify, "run", fake_identify_run):
with mock.patch.object(kling_nodes.AdvancedLipSync, "run", fake_advanced_run):
with mock.patch.object(
kling_nodes,
"_upload_video_reference",
return_value="https://tmpfiles.org/dl/456/source.mp4",
) as upload_mock:
url, video_id = kling_nodes.LipSyncNode().run(
lip_sync_input,
face_id="face-1",
video_input=fake_video,
sound_insert_time_ms=1000,
sound_volume=1.5,
original_audio_volume=0.5,
)
upload_mock.assert_called_once_with(fake_video)
self.assertEqual(url, "https://example.com/lipsync.mp4")
self.assertEqual(video_id, "video-456")
self.assertEqual(
captured["identify_payload"],
{"video_url": "https://tmpfiles.org/dl/456/source.mp4"},
)
self.assertEqual(captured["advanced_payload"]["face_choose"][0]["face_id"], "face-1")
self.assertEqual(captured["advanced_payload"]["face_choose"][0]["audio_id"], "audio-123")
self.assertEqual(captured["advanced_payload"]["face_choose"][0]["sound_insert_time"], 1000)
self.assertEqual(captured["advanced_payload"]["face_choose"][0]["sound_volume"], 1.5)
self.assertEqual(captured["advanced_payload"]["face_choose"][0]["original_audio_volume"], 0.5)
def test_lip_sync_text_input_runs_tts_before_advanced_lip_sync(self):
captured = {}
@contextmanager
def fake_runtime_client():
yield object()
def fake_identify_run(generator, client):
return SimpleNamespace(
session_id="session-text",
face_data=[SimpleNamespace(face_id="face-0", start_time=0, end_time=5000)],
)
def fake_tts_run(generator, client):
captured["tts_payload"] = generator.to_dict()
return SimpleNamespace(
task_result=SimpleNamespace(
audios=[SimpleNamespace(id="audio-text", url="https://example.com/text.mp3", duration="3.0")]
)
)
def fake_advanced_run(generator, client):
captured["advanced_payload"] = generator.to_dict()
return SimpleNamespace(
task_result=SimpleNamespace(
videos=[SimpleNamespace(url="https://example.com/text.mp4", id="video-text")]
)
)
lip_sync_input, = kling_nodes.LipSyncTextInputNode().run(
text="Hello",
voice_id="The Reader",
voice_language="en",
voice_speed=1.0,
)
with mock.patch.object(kling_nodes, "_runtime_client", fake_runtime_client):
with mock.patch.object(kling_nodes.FaceIdentify, "run", fake_identify_run):
with mock.patch.object(kling_nodes.TTS, "run", fake_tts_run):
with mock.patch.object(kling_nodes.AdvancedLipSync, "run", fake_advanced_run):
url, video_id = kling_nodes.LipSyncNode().run(
lip_sync_input,
face_id="",
video_id="video-source",
)
self.assertEqual(url, "https://example.com/text.mp4")
self.assertEqual(video_id, "video-text")
self.assertEqual(captured["tts_payload"]["text"], "Hello")
self.assertEqual(captured["advanced_payload"]["face_choose"][0]["audio_id"], "audio-text")
def test_lip_sync_rejects_unknown_face_id(self):
@contextmanager
def fake_runtime_client():
yield object()
def fake_identify_run(generator, client):
return SimpleNamespace(
session_id="session-123",
face_data=[SimpleNamespace(face_id="face-0", start_time=0, end_time=5000)],
)
lip_sync_input = SimpleNamespace(
mode="audio2video",
audio_id="audio-123",
sound_start_time=0,
sound_end_time=3000,
)
with mock.patch.object(kling_nodes, "_runtime_client", fake_runtime_client):
with mock.patch.object(kling_nodes.FaceIdentify, "run", fake_identify_run):
with self.assertRaisesRegex(ValueError, "was not returned"):
kling_nodes.LipSyncNode().run(
lip_sync_input,
face_id="missing-face",
video_id="video-source",
)
def test_avatar_builds_uploaded_audio_payload(self):
captured = {}
@contextmanager
def fake_runtime_client():
yield object()
def fake_avatar_run(generator, client):
captured["payload"] = generator.to_dict()
return SimpleNamespace(
task_result=SimpleNamespace(
videos=[SimpleNamespace(url="https://example.com/avatar.mp4", id="avatar-123")]
)
)
fake_audio = {"waveform": SimpleNamespace(shape=(1, 1, 132300)), "sample_rate": 44100}
with mock.patch.object(kling_nodes, "_runtime_client", fake_runtime_client):
with mock.patch.object(kling_nodes, "_image_to_base64", return_value="encoded-image"):
with mock.patch.object(
kling_nodes,
"_upload_audio_reference",
return_value="https://example.com/avatar.wav",
):
with mock.patch.object(kling_nodes.Avatar, "run", fake_avatar_run):
url, video_id = kling_nodes.AvatarNode().generate(
image=object(),
mode="std",
audio=fake_audio,
prompt="Smile and speak naturally.",
)
self.assertEqual(url, "https://example.com/avatar.mp4")
self.assertEqual(video_id, "avatar-123")
self.assertEqual(
captured["payload"],
{
"image": "encoded-image",
"mode": "std",
"prompt": "Smile and speak naturally.",
"sound_file": "https://example.com/avatar.wav",
},
)
def test_avatar_rejects_multiple_audio_sources(self):
with self.assertRaisesRegex(ValueError, "Provide only one"):
kling_nodes.AvatarNode().generate(
image=object(),
mode="std",
audio_id="audio-123",
audio_url="https://example.com/audio.mp3",
)
def test_avatar_rejects_out_of_range_comfy_audio_duration(self):
short_audio = {"waveform": SimpleNamespace(shape=(1, 1, 44100)), "sample_rate": 44100}
with mock.patch.object(kling_nodes, "_image_to_base64", return_value="encoded-image"):
with self.assertRaisesRegex(ValueError, "between 2 and 300 seconds"):
kling_nodes.AvatarNode().generate(
image=object(),
mode="std",
audio=short_audio,
)
def test_image2video_custom_voice_builds_voice_list(self):
captured_requests = []
class FakeProvider:
def generate_image2video(self, request):
captured_requests.append(request)
return SimpleNamespace(
url="https://example.com/video.mp4",
video_id="video-voice",
final_unit_deduction=None,
)
def close(self):
pass
with mock.patch.object(kling_nodes, "_create_video_provider", return_value=FakeProvider()):
with mock.patch.object(kling_nodes, "_image_to_base64", return_value="encoded-image"):
url, video_id = kling_nodes.Image2VideoNode().generate(
model="kling-v2-6",
image=object(),
prompt='The person <<<voice_1>>> says: "Hello."',
mode="pro",
duration="5",
sound="on",
custom_voice_id="voice-custom",
)
self.assertEqual(url, "https://example.com/video.mp4")
self.assertEqual(video_id, "video-voice")
self.assertEqual(captured_requests[0].voice_list, [{"voice_id": "voice-custom"}])
def test_image2video_custom_voice_validates_required_contract(self):
common = {
"model": "kling-v2-6",
"image": object(),
"mode": "pro",
"duration": "5",
"custom_voice_id": "voice-custom",
}
with self.assertRaisesRegex(ValueError, "requires sound='on'"):
kling_nodes.Image2VideoNode().generate(
prompt="The person speaks.",
sound="off",
**common,
)
with self.assertRaisesRegex(ValueError, "prompt marker"):
kling_nodes.Image2VideoNode().generate(
prompt="The person speaks.",
sound="on",
**common,
)
with self.assertRaisesRegex(ValueError, "cannot be used together with element_list"):
kling_nodes.Image2VideoNode().generate(
prompt="The person <<<voice_1>>> speaks.",
sound="on",
element_list=[{"element_id": "element-123"}],
**common,
)
with self.assertRaisesRegex(ValueError, "custom_voice_id or voice_preset"):
kling_nodes.Image2VideoNode().generate(
prompt="The person <<<voice_1>>> speaks.",
sound="on",
voice_preset="The Reader",
**common,
)
def test_image2video_custom_voice_rejects_kling_v3(self):
with mock.patch.object(kling_nodes, "_image_to_base64", return_value="encoded-image"):
with self.assertRaisesRegex(ValueError, "does not support voice_list"):
kling_nodes.Image2VideoNode().generate(
model="kling-v3",
image=object(),
prompt='The person <<<voice_1>>> says: "Hello."',
mode="pro",
duration="5",
sound="on",
custom_voice_id="voice-custom",
)
def test_image2video_surfaces_failed_task_details(self):
class FakeProvider:
def generate_image2video(self, request):
raise ValueError(
"Official image2video task task-failed ended with status failed: The input parameters are not correct"
)
def close(self):
pass
with mock.patch.object(kling_nodes, "_create_video_provider", return_value=FakeProvider()):
with mock.patch.object(kling_nodes, "_image_to_base64", return_value="encoded-image"):
with self.assertRaisesRegex(
ValueError,
"task-failed.*The input parameters are not correct",
):
kling_nodes.Image2VideoNode().generate(
model="kling-v2-6",
image=object(),
prompt='The person <<<voice_1>>> says: "Hello."',
mode="pro",
duration="5",
sound="on",
custom_voice_id="voice-custom",
)
def test_text2video_provider_dispatches_to_official_backend(self):
captured_requests = []
class FakeProvider:
def generate_text2video(self, request):
captured_requests.append(request)
return SimpleNamespace(
url="https://example.com/text.mp4",
video_id="text-123",
final_unit_deduction=1.2,
)
def close(self):
pass
with mock.patch.object(kling_nodes, "_create_video_provider", return_value=FakeProvider()):
url, video_id = kling_nodes.Text2VideoNode().generate(
model="kling-v3",
prompt="A fox is running in snow.",
provider=OFFICIAL_PROVIDER,
mode="pro",
duration="3",
aspect_ratio="16:9",
)
self.assertEqual(url, "https://example.com/text.mp4")
self.assertEqual(video_id, "text-123")
self.assertEqual(captured_requests[0].prompt, "A fox is running in snow.")
self.assertEqual(captured_requests[0].mode, "pro")
self.assertEqual(captured_requests[0].aspect_ratio, "16:9")
def test_text2video_tencent_provider_accepts_workflow_fields(self):
captured_requests = []
class FakeProvider:
def generate_text2video(self, request):
captured_requests.append(request)
return SimpleNamespace(url="https://example.com/tencent-text.mp4", video_id="tx-text-123", final_unit_deduction=None)
def close(self):
pass
with mock.patch.object(kling_nodes, "_create_video_provider", return_value=FakeProvider()):
url, video_id = kling_nodes.Text2VideoNode().generate(
model="kling-v3",
prompt="A fox is running in snow.",
provider=TENCENT_VOD_PROVIDER,
mode="pro",
duration="3",
sound="on",
shot_type="intelligence",
element_list=[{"element_id": "el-1"}],
)
self.assertEqual(url, "https://example.com/tencent-text.mp4")
self.assertEqual(video_id, "tx-text-123")
self.assertEqual(captured_requests[0].sound, "on")
self.assertEqual(captured_requests[0].shot_type, "intelligence")
self.assertEqual(captured_requests[0].element_list, [{"element_id": "el-1"}])
def test_image2video_tencent_provider_accepts_workflow_fields_and_requires_primary_image(self):
captured_requests = []
class FakeProvider:
def generate_image2video(self, request):
captured_requests.append(request)
return SimpleNamespace(url="https://example.com/tencent-image.mp4", video_id="tx-image-123", final_unit_deduction=None)
def close(self):
pass
common = {
"model": "kling-v3",
"provider": TENCENT_VOD_PROVIDER,
"mode": "pro",
"duration": "3",
}
with self.assertRaisesRegex(ValueError, "requires a primary IMAGE input"):
kling_nodes.Image2VideoNode().generate(**common)
with mock.patch.object(kling_nodes, "_create_video_provider", return_value=FakeProvider()):
with mock.patch.object(kling_nodes, "_image_to_base64", return_value="encoded-image"):
with mock.patch.object(kling_nodes, "_upload_image_batch_references", side_effect=[["https://cdn.example.com/first.png"], ["https://cdn.example.com/tail.png"]]):
url, video_id = kling_nodes.Image2VideoNode().generate(
image=object(),
image_tail=object(),
sound="on",
element_list=[{"element_id": "el-1"}],
**common,
)
self.assertEqual(url, "https://example.com/tencent-image.mp4")
self.assertEqual(video_id, "tx-image-123")
self.assertEqual(captured_requests[0].sound, "on")
self.assertEqual(captured_requests[0].shot_type, "single")
self.assertEqual(captured_requests[0].image_tail_url, "https://cdn.example.com/tail.png")
def test_tencent_provider_file_infos_and_payload_mapping(self):
provider = TencentVodVideoProvider(
TencentVodVideoConfig(secret_id="sid", secret_key="skey", sub_app_id=123)
)
with mock.patch.object(provider, "_create_task", return_value={"TaskId": "task-image"}) as image_create_mock:
with mock.patch.object(provider, "_wait_for_video", return_value=SimpleNamespace(url="u", video_id="v")):
provider.generate_image2video(
kling_nodes.Image2VideoRequest(
model_name="kling-v3",
image_urls=[
"https://cdn.example.com/a.png",
"https://cdn.example.com/b.png",
],
image_tail_url="https://cdn.example.com/tail.png",
reference_video="https://cdn.example.com/ref.mp4",
mode="std",
duration="3",
)
)
image_payload = image_create_mock.call_args[0][0]
self.assertEqual(image_payload["FileInfos"][0]["Usage"], "FirstFrame")
self.assertEqual(image_payload["FileInfos"][1]["Usage"], "Reference")
self.assertEqual(image_payload["FileInfos"][2]["Category"], "Video")
self.assertEqual(image_payload["FileInfos"][2]["ReferenceType"], "feature")
self.assertEqual(image_payload["LastFrameUrl"], "https://cdn.example.com/tail.png")
with mock.patch.object(provider, "_create_task", return_value={"TaskId": "task-123"}) as create_mock:
with mock.patch.object(provider, "_wait_for_video", return_value=SimpleNamespace(url="u", video_id="v")):
provider.generate_text2video(
kling_nodes.Text2VideoRequest(
model_name="kling-v3",
prompt="demo",
mode="pro",
aspect_ratio="9:16",
duration="5",
sound="off",
)
)
payload = create_mock.call_args[0][0]
self.assertEqual(payload["GenerationMode"], "Professional")
self.assertEqual(payload["OutputConfig"]["AspectRatio"], "9:16")
self.assertEqual(payload["OutputConfig"]["Duration"], 5.0)
self.assertEqual(payload["OutputConfig"]["AudioGeneration"], "Disabled")
def test_tencent_provider_validates_prompt_size_before_submission(self):
provider = TencentVodVideoProvider(
TencentVodVideoConfig(secret_id="sid", secret_key="skey", sub_app_id=123)
)
with mock.patch.object(provider, "_create_task", return_value={"TaskId": "task-boundary"}) as create_mock:
with mock.patch.object(provider, "_wait_for_video", return_value=SimpleNamespace(url="u", video_id="v")):
provider.generate_image2video(
kling_nodes.Image2VideoRequest(
model_name="kling-v3",
prompt="字" * 2500,
image_urls=["https://cdn.example.com/first.png"],
)
)
self.assertEqual(len(create_mock.call_args[0][0]["Prompt"]), 2500)
with mock.patch.object(provider, "_create_task", return_value={"TaskId": "task-too-long"}) as create_mock:
with mock.patch.object(provider, "_wait_for_video", return_value=SimpleNamespace(url="u", video_id="v")):
with self.assertRaisesRegex(
ValueError,
r"TENCENT_VOD_PROMPT_SIZE_INVALID.*2501.*2500.*1201",
):
provider.generate_image2video(
kling_nodes.Image2VideoRequest(
model_name="kling-v3",
prompt="字" * 2501,
image_urls=["https://cdn.example.com/first.png"],
)
)
create_mock.assert_not_called()
def test_tencent_provider_maps_kling_v3_turbo_without_subject_references(self):
provider = TencentVodVideoProvider(
TencentVodVideoConfig(secret_id="sid", secret_key="skey", sub_app_id=123)
)
with mock.patch.object(provider, "_create_task", return_value={"TaskId": "task-turbo"}) as create_mock:
with mock.patch.object(provider, "_wait_for_video", return_value=SimpleNamespace(url="u", video_id="v")):
provider.generate_image2video(
kling_nodes.Image2VideoRequest(
model_name="kling-v3-turbo",
prompt="animate this",
mode="4k",
duration="15",
image_urls=["https://cdn.example.com/first.png"],
)
)
payload = create_mock.call_args[0][0]
self.assertEqual(payload["ModelVersion"], "3.0-turbo")
self.assertEqual(payload["OutputConfig"]["Resolution"], "4K")
self.assertEqual(payload["OutputConfig"]["Duration"], 15.0)
self.assertEqual(
payload["FileInfos"],
[{
"Type": "Url",
"Category": "Image",
"Url": "https://cdn.example.com/first.png",
"Usage": "FirstFrame",
}],
)
self.assertNotIn("SubjectInfos", payload)
for request in (
kling_nodes.Text2VideoRequest(
model_name="kling-v3-turbo",
prompt="demo",
element_list=[{"element_id": "element-123"}],
),
kling_nodes.Image2VideoRequest(
model_name="kling-v3-turbo",
prompt="demo",
image_urls=["https://cdn.example.com/first.png"],
element_list=[{"element_id": "element-123"}],
),
):
with self.subTest(request=type(request).__name__):
with self.assertRaisesRegex(ValueError, "does not support subject references"):
if isinstance(request, kling_nodes.Text2VideoRequest):
provider.generate_text2video(request)
else:
provider.generate_image2video(request)
def test_tencent_provider_merges_extra_payload_and_output_config(self):
provider = TencentVodVideoProvider(
TencentVodVideoConfig(secret_id="sid", secret_key="skey", sub_app_id=123)
)
with mock.patch.object(provider, "_create_task", return_value={"TaskId": "task-extra-123"}) as create_mock:
with mock.patch.object(provider, "_wait_for_video", return_value=SimpleNamespace(url="u", video_id="v")):
provider.generate_text2video(
kling_nodes.Text2VideoRequest(
model_name="kling-v3",
prompt="demo",
mode="pro",
aspect_ratio="9:16",
duration="5",
sound="on",
cfg_scale=0.8,
extra_payload={
"CfgScale": 0.1,
"CustomField": "demo",
},
)
)
payload = create_mock.call_args[0][0]
self.assertEqual(payload["OutputConfig"]["AudioGeneration"], "Enabled")
ext_info = json.loads(payload["ExtInfo"])
self.assertEqual(ext_info["CfgScale"], 0.8)
self.assertEqual(ext_info["CustomField"], "demo")
def test_tencent_provider_extracts_task_results(self):
provider = TencentVodVideoProvider(
TencentVodVideoConfig(secret_id="sid", secret_key="skey", sub_app_id=123)
)
detail = {
"Tasks": [
{
"TaskId": "task-123",
"Status": "SUCCESS",
"Output": {
"FileInfos": [
{"FileUrl": "https://example.com/video.mp4", "VideoId": "video-123"}
]
},
}
]
}
task = provider._extract_task(detail)
self.assertEqual(provider._normalize_status(task), "SUCCESS")
self.assertEqual(provider._extract_urls(task), ["https://example.com/video.mp4"])
def test_tencent_provider_wait_for_video_returns_result(self):
provider = TencentVodVideoProvider(
TencentVodVideoConfig(secret_id="sid", secret_key="skey", sub_app_id=123, poll_interval=0.01, request_timeout=1)
)
with mock.patch.object(provider, "_describe_task", return_value={
"TaskId": "task-456",
"Status": "SUCCESS",
"Output": {
"FileInfos": [
{"FileUrl": "https://example.com/final.mp4", "VideoId": "video-456"}
]
},
}):
result = provider._wait_for_video("task-456")
self.assertEqual(result.url, "https://example.com/final.mp4")
self.assertEqual(result.video_id, "video-456")
self.assertEqual(result.task_id, "task-456")
def test_example_workflow_links_target_valid_input_slots(self):
workflow_paths = sorted((REPO_ROOT / "examples").glob("*.json"))
self.assertGreaterEqual(len(workflow_paths), 20)
for workflow_path in workflow_paths:
with self.subTest(filename=workflow_path.name):
data = json.loads(workflow_path.read_text(encoding="utf-8"))
nodes_by_id = {node["id"]: node for node in data["nodes"]}
for link_id, _, _, destination_id, destination_slot, _ in data["links"]:
destination_inputs = nodes_by_id[destination_id].get("inputs", [])
self.assertLess(destination_slot, len(destination_inputs))
self.assertEqual(destination_inputs[destination_slot].get("link"), link_id)
def test_runtime_client_prefers_config_local_json(self):
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = Path(tmpdir)
json_path = tmpdir_path / "config.local.json"
legacy_path = tmpdir_path / "config.ini"
json_path.write_text(
json.dumps(
{
"access_key": "json-ak",
"secret_key": "json-sk",
"poll_interval": 2.5,
"request_timeout": 45,
"area": "china",
}
),
encoding="utf-8",
)
legacy_path.write_text(
"[API]\nKLINGAI_API_ACCESS_KEY = legacy-ak\nKLINGAI_API_SECRET_KEY = legacy-sk\n",
encoding="utf-8",
)
with mock.patch.dict(os.environ, {**ENV_KEYS, "KLINGAI_API_ACCESS_KEY": "env-ak"}, clear=False):
with mock.patch.object(kling_nodes, "CONFIG_JSON_PATH", json_path):
with mock.patch.object(kling_nodes, "LEGACY_CONFIG_PATH", legacy_path):
client = kling_nodes._create_runtime_client()
self.assertEqual(client._access_key, "json-ak")
self.assertEqual(client._secret_key, "json-sk")
self.assertEqual(client._timeout, 45)
self.assertEqual(client.poll_interval, 2.5)
self.assertEqual(client._area.name, "CHINA")
def test_runtime_client_falls_back_to_legacy_config(self):
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = Path(tmpdir)
json_path = tmpdir_path / "missing-config.local.json"
legacy_path = tmpdir_path / "config.ini"
legacy_path.write_text(
"[API]\nKLINGAI_API_ACCESS_KEY = legacy-ak\nKLINGAI_API_SECRET_KEY = legacy-sk\n",
encoding="utf-8",
)
with mock.patch.dict(os.environ, ENV_KEYS, clear=False):
with mock.patch.object(kling_nodes, "CONFIG_JSON_PATH", json_path):
with mock.patch.object(kling_nodes, "LEGACY_CONFIG_PATH", legacy_path):
client = kling_nodes._create_runtime_client()
self.assertEqual(client._access_key, "legacy-ak")
self.assertEqual(client._secret_key, "legacy-sk")
self.assertEqual(client._timeout, kling_nodes.DEFAULT_REQUEST_TIMEOUT)
self.assertEqual(client.poll_interval, kling_nodes.DEFAULT_POLL_INTERVAL)
self.assertEqual(client._area.name, "GLOBAL")
def test_video_provider_default_uses_official_when_missing(self):
with tempfile.TemporaryDirectory() as tmpdir:
json_path = Path(tmpdir) / "config.local.json"
json_path.write_text(json.dumps({}), encoding="utf-8")
with mock.patch.object(kling_nodes, "CONFIG_JSON_PATH", json_path):
self.assertEqual(kling_nodes._resolve_video_provider_default(), OFFICIAL_PROVIDER)
def test_node_uses_provider_default_from_backend_config(self):
class FakeProvider:
def generate_text2video(self, request):
return SimpleNamespace(
url="https://example.com/video.mp4",
video_id="video-1",
final_unit_deduction=None,
)
def close(self):
pass
with tempfile.TemporaryDirectory() as tmpdir:
json_path = Path(tmpdir) / "config.local.json"
json_path.write_text(
json.dumps({"provider_default": TENCENT_VOD_PROVIDER}),
encoding="utf-8",
)
with mock.patch.object(kling_nodes, "CONFIG_JSON_PATH", json_path):
with mock.patch.object(
kling_nodes,
"_create_video_provider",
return_value=FakeProvider(),
) as create_provider:
result = kling_nodes.Text2VideoNode().generate(
model="kling-v3",
prompt="A fox runs through snow.",
mode="std",
duration="3",
)
self.assertEqual(result, ("https://example.com/video.mp4", "video-1"))
create_provider.assert_called_once_with(TENCENT_VOD_PROVIDER)
def test_video_provider_default_rejects_unknown_value(self):
with tempfile.TemporaryDirectory() as tmpdir:
json_path = Path(tmpdir) / "config.local.json"
json_path.write_text(json.dumps({"provider_default": "bad-provider"}), encoding="utf-8")
with mock.patch.object(kling_nodes, "CONFIG_JSON_PATH", json_path):
with self.assertRaisesRegex(ValueError, "provider_default must be one of"):
kling_nodes._resolve_video_provider_default()
def test_create_video_provider_returns_official_provider(self):
with tempfile.TemporaryDirectory() as tmpdir:
json_path = Path(tmpdir) / "config.local.json"
json_path.write_text(
json.dumps(
{
"provider_default": OFFICIAL_PROVIDER,
"access_key": "json-ak",
"secret_key": "json-sk",
"area": "china",
"poll_interval": 2.5,
"request_timeout": 45,
}
),
encoding="utf-8",
)
with mock.patch.object(kling_nodes, "CONFIG_JSON_PATH", json_path):
provider = kling_nodes._create_video_provider(OFFICIAL_PROVIDER)
self.assertIsInstance(provider, OfficialVideoProvider)
self.assertEqual(provider._config, OfficialVideoConfig(
access_key="json-ak",
secret_key="json-sk",
in_china=True,
timeout=45,
poll_interval=2.5,
))
def test_official_provider_accepts_api_key_without_aksk(self):
with tempfile.TemporaryDirectory() as tmpdir:
json_path = Path(tmpdir) / "config.local.json"
json_path.write_text(
json.dumps(
{
"provider_default": OFFICIAL_PROVIDER,
"api_key": "json-api-key",
"area": "china",
"poll_interval": 2.5,
"request_timeout": 45,
}
),
encoding="utf-8",
)
with mock.patch.object(kling_nodes, "CONFIG_JSON_PATH", json_path):
provider = kling_nodes._create_video_provider(OFFICIAL_PROVIDER)
client = kling_nodes._create_runtime_client()
self.assertIsInstance(provider, OfficialVideoProvider)
self.assertEqual(provider._config.access_key, "")
self.assertEqual(provider._config.secret_key, "")
self.assertEqual(provider._config.api_key, "json-api-key")
self.assertEqual(client._api_key, "json-api-key")
client.close()
def test_create_video_provider_returns_tencent_provider(self):
with tempfile.TemporaryDirectory() as tmpdir:
json_path = Path(tmpdir) / "config.local.json"
json_path.write_text(
json.dumps(
{
"provider_default": TENCENT_VOD_PROVIDER,
"tencent_secret_id": "sid",
"tencent_secret_key": "skey",
"tencent_sub_app_id": 123,
"tencent_region": "ap-shanghai",
"tencent_poll_interval": 6,
"tencent_request_timeout": 90,
}
),
encoding="utf-8",
)
with mock.patch.object(kling_nodes, "CONFIG_JSON_PATH", json_path):
provider = kling_nodes._create_video_provider(TENCENT_VOD_PROVIDER)
self.assertIsInstance(provider, TencentVodVideoProvider)
self.assertEqual(provider._config, TencentVodVideoConfig(
secret_id="sid",
secret_key="skey",
sub_app_id=123,
region="ap-shanghai",
request_timeout=90,
poll_interval=6.0,
))
def test_create_video_provider_requires_tencent_credentials(self):
with tempfile.TemporaryDirectory() as tmpdir:
json_path = Path(tmpdir) / "config.local.json"
json_path.write_text(json.dumps({"provider_default": TENCENT_VOD_PROVIDER}), encoding="utf-8")
with mock.patch.dict(os.environ, ENV_KEYS, clear=False):
with mock.patch.object(kling_nodes, "CONFIG_JSON_PATH", json_path):
with self.assertRaisesRegex(ValueError, "Tencent VOD provider requires"):
kling_nodes._create_video_provider(TENCENT_VOD_PROVIDER)
def test_create_video_provider_accepts_standard_tencent_environment_names(self):
with tempfile.TemporaryDirectory() as tmpdir:
json_path = Path(tmpdir) / "config.local.json"
json_path.write_text(
json.dumps({
"tencent_sub_app_id": 123,
"tencent_max_wait_seconds": 2400,
}),
encoding="utf-8",
)
environment = dict(ENV_KEYS)
environment.update({
"TENCENTCLOUD_SECRET_ID": "cloud-sid",
"TENCENTCLOUD_SECRET_KEY": "cloud-skey",
})
with mock.patch.dict(os.environ, environment, clear=False):
with mock.patch.object(kling_nodes, "CONFIG_JSON_PATH", json_path):
provider = kling_nodes._create_video_provider(TENCENT_VOD_PROVIDER)
self.assertEqual(provider._config.secret_id, "cloud-sid")
self.assertEqual(provider._config.secret_key, "cloud-skey")
self.assertEqual(provider._config.max_wait_seconds, 2400)
def test_video_extend_uses_backend_runtime_client(self):
sentinel_client = object()
@contextmanager
def fake_runtime_client():
yield sentinel_client
class FakeVideoExtend:
def __init__(self):
self.video_id = None
self.prompt = None
def run(self, client):
self.seen_client = client
return SimpleNamespace(
task_result=SimpleNamespace(
videos=[SimpleNamespace(url="https://example.com/video.mp4", id="video-123")]
)
)
with mock.patch.object(kling_nodes, "_runtime_client", fake_runtime_client):
with mock.patch.object(kling_nodes, "VideoExtend", FakeVideoExtend):
node = kling_nodes.VideoExtendNode()
url, video_id = node.run("video-001", "Continue the shot naturally.")
self.assertEqual(url, "https://example.com/video.mp4")
self.assertEqual(video_id, "video-123")
def test_advanced_element_image_subject_sends_minimal_payload(self):
sentinel_client = object()
captured = {}
@contextmanager
def fake_runtime_client():
yield sentinel_client
def fake_run(generator, client):
captured["client"] = client
captured["payload"] = generator.to_dict()
return SimpleNamespace(
task_id="task-123", final_unit_deduction=1.0,
task_result={"elements": [{"element_id": "element-123"}]},
)
with mock.patch.object(kling_nodes, "_runtime_client", fake_runtime_client):
with mock.patch.object(kling_nodes.AdvancedCustomElements, "run", fake_run):
with mock.patch.object(
kling_nodes,
"_upload_image_batch_references",
side_effect=[
["https://cdn.example.com/front.png"],
[
"https://cdn.example.com/left.png",
"https://cdn.example.com/right.png",
"https://cdn.example.com/rear.png",
],
],
):
payload, element_id, element_json = kling_nodes.AdvancedCustomElementCreateNode().create(
element_type="image_subject",
element_name="shoe",
element_description="Nike Kobe Air Force 1 Low",
image=object(),
image_list=object(),
element_voice_id="voice-123",
)
self.assertEqual(
captured["payload"],
{
"reference_type": "image_refer",
"element_image_list": {
"frontal_image": "https://cdn.example.com/front.png",
"refer_images": [
{"image_url": "https://cdn.example.com/left.png"},
{"image_url": "https://cdn.example.com/right.png"},
{"image_url": "https://cdn.example.com/rear.png"},
],
},
"element_voice_id": "voice-123",
"element_name": "shoe",
"element_description": "Nike Kobe Air Force 1 Low",
},
)
self.assertIs(captured["client"], sentinel_client)
self.assertEqual(payload, {"element_id": "element-123"})
self.assertEqual(element_id, "element-123")
self.assertEqual(json.loads(element_json), payload)
def test_advanced_element_video_subject_sends_minimal_payload(self):
sentinel_client = object()
captured = {}
@contextmanager
def fake_runtime_client():
yield sentinel_client
def fake_run(generator, client):
captured["client"] = client
captured["payload"] = generator.to_dict()
return SimpleNamespace(
task_id="task-456", final_unit_deduction=1.0,
task_result={"elements": [{"element_id": "element-456"}]},
)
with mock.patch.object(kling_nodes, "_runtime_client", fake_runtime_client):
with mock.patch.object(kling_nodes.AdvancedCustomElements, "run", fake_run):
kling_nodes.AdvancedCustomElementCreateNode().create(
element_type="video_character",
element_name="actor",
element_description="Character reference",
video_url=" https://example.com/reference.mp4 ",
)
self.assertEqual(
captured["payload"],
{
"reference_type": "video_refer",
"element_video_list": {
"refer_videos": [{"video_url": "https://example.com/reference.mp4"}],
},
"element_name": "actor",
"element_description": "Character reference",
},
)
self.assertIs(captured["client"], sentinel_client)
def test_advanced_element_uploads_local_video_for_all_providers(self):
class FakeVideo:
def get_duration(self):
return 8.0
def save_to(self, path):
Path(path).write_bytes(b"reference-video")
uploaded_paths = []
def upload(path):
self.assertEqual(Path(path).read_bytes(), b"reference-video")
uploaded_paths.append(path)
return "https://example.com/uploaded-reference.mp4"
expected = {"refer_videos": [{"video_url": "https://example.com/uploaded-reference.mp4"}]}
for provider in (OFFICIAL_PROVIDER, TENCENT_VOD_PROVIDER, kling_providers.VAPEUR_PROVIDER):
with self.subTest(provider=provider):
captured = {}
def capture(generator, client):
captured.update(generator.to_dict())
return SimpleNamespace(
task_id="task-123", task_result={"elements": [{"element_id": "element-123"}]},
)
with (
mock.patch.object(kling_nodes, "_upload_file_to_temporary_media_host", side_effect=upload),
mock.patch.object(kling_nodes, "_runtime_client"),
mock.patch.object(kling_nodes.AdvancedCustomElements, "run", capture),
mock.patch.object(kling_nodes, "_video_provider") as backend,
):
backend.return_value.__enter__.return_value.create_element.return_value = SimpleNamespace(
element={"element_id": "element-123"}, element_id="element-123"
)
kling_nodes.AdvancedCustomElementCreateNode().create(
element_type="video_character",
element_name="actor",
element_description="Character reference",
provider=provider,
reference_video_input=FakeVideo(),
video_url="https://example.com/unused.mp4",
)
if provider == OFFICIAL_PROVIDER:
self.assertEqual(captured["element_video_list"], expected)
self.assertEqual(captured["reference_type"], "video_refer")
else:
request = backend.return_value.__enter__.return_value.create_element.call_args.args[0]
self.assertEqual(request.element_video_list, expected)
self.assertEqual(request.reference_type, "video_refer")
self.assertEqual(len(uploaded_paths), 3)
self.assertTrue(all(not Path(path).exists() for path in uploaded_paths))
def test_advanced_element_rejects_long_video_before_upload(self):
for duration in (8.001, 12.0):
with (
self.subTest(duration=duration),
mock.patch.object(kling_nodes, "_upload_video_reference") as upload_video,
mock.patch.object(kling_nodes, "_upload_image_batch_references") as upload_images,
mock.patch.object(kling_nodes, "_runtime_client") as runtime,
mock.patch.object(kling_nodes, "_video_provider") as backend,
):
video = mock.Mock()
video.get_duration.return_value = duration
with self.assertRaisesRegex(ValueError, "8"):
kling_nodes.AdvancedCustomElementCreateNode().create(
element_type="video_character", element_name="actor",
element_description="Character reference", reference_video_input=video,
image=object(), image_list=object(),
)
upload_video.assert_not_called()
upload_images.assert_not_called()
runtime.assert_not_called()
backend.assert_not_called()
video.save_to.assert_not_called()
def test_advanced_element_video_requires_reference(self):
with self.assertRaisesRegex(ValueError, "reference_video_input or video_url"):
kling_nodes.AdvancedCustomElementCreateNode().create(
element_type="video_character", element_name="actor", element_description="Character reference"
)
def test_advanced_element_create_result_feeds_list_builder(self):
task_data = {
"task_id": "task-123", "task_status": "succeed",
"task_result": {"elements": [{"element_id": 123456789, "element_type": "video_character"}]},
}
with mock.patch.object(kling_nodes, "_runtime_client") as runtime:
client = runtime.return_value.__enter__.return_value
client.poll_interval = 0
client.request.side_effect = [
{"data": {"task_id": "task-123"}}, {"data": task_data},
]
element, element_id, _ = kling_nodes.AdvancedCustomElementCreateNode().create(
element_type="video_character", element_name="actor", element_description="Character reference",
video_url="https://example.com/reference.mp4",
)
self.assertEqual(element_id, "123456789")
elements, _ = kling_nodes.ElementListBuilderNode().build(element_1=element)
self.assertEqual(elements, [{"element_id": "123456789", "type": "video_character"}])
def test_advanced_element_create_reports_failed_and_empty_results(self):
for status, result, message, expected in (
("failed", {}, "Video duration can not longer than 8s", "Video duration can not longer than 8s"),
("succeed", {}, "", "returned no element_id"),
("succeed", {"elements": [{}]}, "", "returned no element_id"),
):
with self.subTest(status=status, result=result):
with mock.patch.object(kling_nodes, "_runtime_client") as runtime:
client = runtime.return_value.__enter__.return_value
client.poll_interval = 0
client.request.side_effect = [
{"data": {"task_id": "task-123"}},
{"data": {"task_id": "task-123", "task_status": status,
"task_status_msg": message, "task_result": result}},
]
with self.assertRaisesRegex(ValueError, expected) as error:
kling_nodes.AdvancedCustomElementCreateNode().create(
element_type="video_character", element_name="actor",
element_description="Character reference", video_url="https://example.com/reference.mp4",
)
self.assertIn("task-123", str(error.exception))
def test_preview_video_returns_local_video_url_when_saved(self):
self.assertEqual(
kling_nodes.DEFAULT_VIDEO_FILENAME_PREFIX,
"video/Kling_%year%%month%%day%_%hour%%minute%%second%",
)
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = Path(tmpdir)
with mock.patch.object(kling_nodes.folder_paths, "get_output_directory", return_value=str(tmpdir_path)):
with mock.patch.object(
kling_nodes.folder_paths,
"get_save_image_path",
return_value=(str(tmpdir_path), "Kling_20260718_180410", 1, "video", None),
) as save_path_mock:
with mock.patch.object(kling_nodes, "_fetch_image", return_value=b"video-bytes"):
with mock.patch.object(kling_nodes, "_register_output_asset"):
result = kling_nodes.PreviewVideo().run(
"https://example.com/video.mp4",
)
save_path_mock.assert_called_once_with(kling_nodes.DEFAULT_VIDEO_FILENAME_PREFIX, str(tmpdir_path))
self.assertEqual(
result["ui"]["video_url"],
["/api/view?type=output&filename=Kling_20260718_180410_00001_.mp4&subfolder=video"],
)
self.assertEqual(
result["ui"]["images"][0]["filename"],
"Kling_20260718_180410_00001_.mp4",
)
self.assertEqual(result["ui"]["images"][0]["subfolder"], "video")
def test_preview_video_filename_controls_are_not_user_toggleable(self):
required = kling_nodes.PreviewVideo.INPUT_TYPES()["required"]
self.assertNotIn("save_output", required)
self.assertNotIn("filename_prefix", required)
def test_preview_video_registers_saved_file_as_asset(self):
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = Path(tmpdir)
with mock.patch.object(kling_nodes.folder_paths, "get_output_directory", return_value=str(tmpdir_path)):
with mock.patch.object(
kling_nodes.folder_paths,
"get_save_image_path",
return_value=(str(tmpdir_path), "timeline", 1, "video", None),
):
with mock.patch.object(kling_nodes, "_fetch_image", return_value=b"video-bytes"):
with mock.patch.object(kling_nodes, "_register_output_asset") as register_mock:
result = kling_nodes.PreviewVideo().run(
"https://example.com/video.mp4",
)
register_mock.assert_called_once_with(result["result"][0])
def test_preview_video_skips_missing_asset_ingest_function(self):
fake_app_module = ModuleType("app")
fake_assets_module = ModuleType("app.assets")
fake_services_module = ModuleType("app.assets.services")
fake_ingest_module = ModuleType("app.assets.services.ingest")
fake_ingest_module.ingest_existing_file = None
with mock.patch.dict(
sys.modules,
{
"app": fake_app_module,
"app.assets": fake_assets_module,
"app.assets.services": fake_services_module,
"app.assets.services.ingest": fake_ingest_module,
},
):
kling_nodes._register_output_asset("example.mp4")
def test_preview_video_silences_broken_asset_ingest_function(self):
fake_app_module = ModuleType("app")
fake_assets_module = ModuleType("app.assets")
fake_services_module = ModuleType("app.assets.services")
fake_ingest_module = ModuleType("app.assets.services.ingest")
def broken_ingest(_file_path):
raise TypeError("'NoneType' object is not callable")
fake_ingest_module.ingest_existing_file = broken_ingest
with mock.patch.dict(
sys.modules,
{
"app": fake_app_module,
"app.assets": fake_assets_module,
"app.assets.services": fake_services_module,
"app.assets.services.ingest": fake_ingest_module,
},
):
with mock.patch("builtins.print") as print_mock:
kling_nodes._register_output_asset("example.mp4")
print_mock.assert_not_called()
def test_preview_video_keeps_result_when_asset_registration_fails(self):
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = Path(tmpdir)
with mock.patch.object(kling_nodes.folder_paths, "get_output_directory", return_value=str(tmpdir_path)):
with mock.patch.object(
kling_nodes.folder_paths,
"get_save_image_path",
return_value=(str(tmpdir_path), "timeline", 1, "video", None),
):
with mock.patch.object(kling_nodes, "_fetch_image", return_value=b"video-bytes"):
with mock.patch.object(kling_nodes, "_register_output_asset", side_effect=RuntimeError("boom")):
result = kling_nodes.PreviewVideo().run(
"https://example.com/video.mp4",
)
self.assertTrue(Path(result["result"][0]).is_file())
def test_preview_video_rejects_empty_video_url(self):
with self.assertRaisesRegex(
ValueError,
"empty video_url",
):
kling_nodes.PreviewVideo().run("")
def test_upload_file_to_tmpfiles_retries_transient_ssl_error(self):
with tempfile.TemporaryDirectory() as tmpdir:
file_path = Path(tmpdir) / "sample.mp4"
file_path.write_bytes(b"video-bytes")
success_response = mock.Mock()
success_response.raise_for_status.return_value = None
success_response.json.return_value = {
"status": "success",
"data": {"url": "https://tmpfiles.org/123/sample.mp4"},
}
with mock.patch.object(
kling_nodes.requests,
"post",
side_effect=[
requests.exceptions.SSLError("EOF occurred in violation of protocol"),
success_response,
],
) as post_mock:
url = kling_nodes._upload_file_to_tmpfiles(file_path)
self.assertEqual(url, "https://tmpfiles.org/dl/123/sample.mp4")
self.assertEqual(post_mock.call_count, 2)
def test_upload_image_batch_references_uploads_each_frame_and_removes_temp_files(self):
uploaded_paths = []
class FakeFrame:
def save(self, path, format):
self.path = path
self.format = format
Path(path).write_bytes(b"png-bytes")
frames = [FakeFrame(), FakeFrame()]
def fake_upload(path):
uploaded_paths.append(path)
self.assertTrue(Path(path).is_file())
return f"https://cdn.example.com/{len(uploaded_paths)}.png"
with mock.patch.object(kling_nodes, "_tensor2images", return_value=frames):
with mock.patch.object(
kling_nodes,
"_upload_file_to_temporary_media_host",
side_effect=fake_upload,
):
urls = kling_nodes._upload_image_batch_references(object())
self.assertEqual(
urls,
[
"https://cdn.example.com/1.png",
"https://cdn.example.com/2.png",
],
)
self.assertEqual([frame.format for frame in frames], ["PNG", "PNG"])
self.assertTrue(all(not Path(path).exists() for path in uploaded_paths))
def test_upload_file_to_oss_returns_signed_download_url(self):
with tempfile.TemporaryDirectory() as tmpdir:
file_path = Path(tmpdir) / "sample.mp4"
file_path.write_bytes(b"video-bytes")
ok_response = mock.Mock()
ok_response.raise_for_status.return_value = None
oss_config = {
"scheme": "https",
"endpoint_host": "oss-cn-hangzhou.aliyuncs.com",
"access_key_id": "access-key-id",
"access_key_secret": "access-key-secret",
"bucket": "example-bucket",
"prefix": "GouMei-Video-Cut",
"signed_url_expires": 3600,
}
with mock.patch.object(kling_nodes, "_build_oss_object_key", return_value="GouMei-Video-Cut/sample.mp4"):
with mock.patch.object(kling_nodes.time, "time", return_value=1000):
with mock.patch.object(kling_nodes.requests, "put", return_value=ok_response) as put_mock:
url = kling_nodes._upload_file_to_oss(file_path, oss_config=oss_config)
put_mock.assert_called_once()
self.assertEqual(
put_mock.call_args.args[0],
"https://example-bucket.oss-cn-hangzhou.aliyuncs.com/GouMei-Video-Cut/sample.mp4",
)
self.assertIn("Authorization", put_mock.call_args.kwargs["headers"])
self.assertTrue(put_mock.call_args.kwargs["headers"]["Authorization"].startswith("OSS access-key-id:"))
parsed_url = urllib.parse.urlparse(url)
query = urllib.parse.parse_qs(parsed_url.query)
self.assertEqual(parsed_url.scheme, "https")
self.assertEqual(parsed_url.netloc, "example-bucket.oss-cn-hangzhou.aliyuncs.com")
self.assertEqual(parsed_url.path, "/GouMei-Video-Cut/sample.mp4")
self.assertEqual(query["OSSAccessKeyId"], ["access-key-id"])
self.assertEqual(query["Expires"], ["4600"])
self.assertIn("Signature", query)
def test_oss_upload_config_reads_environment_variables(self):
with tempfile.TemporaryDirectory() as tmpdir:
json_path = Path(tmpdir) / "missing-config.local.json"
env_values = {
**ENV_KEYS,
"OSS_ENDPOINT": "oss-cn-hangzhou.aliyuncs.com",
"OSS_ACCESS_KEY_ID": "access-key-id",
"OSS_ACCESS_KEY_SECRET": "access-key-secret",
"OSS_BUCKET": "example-bucket",
"OSS_PREFIX": "GouMei-Video-Cut",
"OSS_SIGNED_URL_EXPIRES": "7200",
}
with mock.patch.dict(os.environ, env_values, clear=False):
with mock.patch.object(kling_nodes, "CONFIG_JSON_PATH", json_path):
config = kling_nodes._resolve_oss_upload_config()
self.assertEqual(config["scheme"], "https")
self.assertEqual(config["endpoint_host"], "oss-cn-hangzhou.aliyuncs.com")
self.assertEqual(config["access_key_id"], "access-key-id")
self.assertEqual(config["access_key_secret"], "access-key-secret")
self.assertEqual(config["bucket"], "example-bucket")
self.assertEqual(config["prefix"], "GouMei-Video-Cut")
self.assertEqual(config["signed_url_expires"], 7200)
def test_temporary_media_upload_falls_back_to_catbox(self):
with tempfile.TemporaryDirectory() as tmpdir:
file_path = Path(tmpdir) / "sample.mp4"
file_path.write_bytes(b"video-bytes")
catbox_response = mock.Mock()
catbox_response.raise_for_status.return_value = None
catbox_response.text = "https://files.catbox.moe/sample.mp4"
with mock.patch.object(kling_nodes.time, "sleep"):
with mock.patch.object(kling_nodes, "_resolve_oss_upload_config", return_value=None):
with mock.patch.object(
kling_nodes.requests,
"post",
side_effect=[
requests.exceptions.ConnectionError("reset"),
requests.exceptions.ConnectionError("reset"),
requests.exceptions.ConnectionError("reset"),
catbox_response,
],
) as post_mock:
url = kling_nodes._upload_file_to_temporary_media_host(file_path)
self.assertEqual(url, "https://files.catbox.moe/sample.mp4")
self.assertEqual(post_mock.call_count, 4)
self.assertEqual(
[call.args[0] for call in post_mock.call_args_list],
[
kling_nodes.TMPFILES_UPLOAD_API_URL,
kling_nodes.TMPFILES_UPLOAD_API_URL,
kling_nodes.TMPFILES_UPLOAD_API_URL,
kling_nodes.CATBOX_UPLOAD_API_URL,
],
)
def test_motion_control_exposes_model_dropdown(self):
input_types = kling_nodes.MotionControlNode.INPUT_TYPES()
required = input_types["required"]
model_options, metadata = required["model_name"]
duration_options, duration_metadata = input_types["optional"]["duration"]
self.assertEqual(model_options, kling_nodes.MOTION_CONTROL_MODELS)
self.assertEqual(metadata["default"], "kling-v2-6")
self.assertEqual(duration_options, kling_nodes.MOTION_CONTROL_DURATIONS)
self.assertEqual(duration_metadata["default"], "auto")
self.assertIn("reference_video", input_types["optional"])
def test_motion_control_uploads_reference_video_frames(self):
sentinel_client = object()
@contextmanager
def fake_runtime_client():
yield sentinel_client
class FakeMotionControl:
last_instance = None
def __init__(self):
FakeMotionControl.last_instance = self
def run(self, client):
self.seen_client = client
return SimpleNamespace(
final_unit_deduction=1.0,
task_result=SimpleNamespace(
videos=[SimpleNamespace(url="https://example.com/video.mp4", id="video-123")]
),
)
fake_frames = object()
with mock.patch.object(kling_nodes, "_runtime_client", fake_runtime_client):
with mock.patch.object(kling_nodes, "MotionControl", FakeMotionControl):
with mock.patch.object(kling_nodes, "_upload_image_reference", return_value="https://tmpfiles.org/dl/img/ref.png"):
with mock.patch.object(
kling_nodes,
"_upload_reference_video_frames",
return_value="https://tmpfiles.org/dl/123/reference.mp4",
) as upload_mock:
url, video_id = kling_nodes.MotionControlNode().generate(
model_name="kling-v2-6",
reference_image=object(),
reference_video_frames=fake_frames,
reference_video_info={"loaded_duration": 12.5, "loaded_fps": 12.5},
)
self.assertEqual(url, "https://example.com/video.mp4")
self.assertEqual(video_id, "video-123")
upload_mock.assert_called_once_with(fake_frames, {"loaded_duration": 12.5, "loaded_fps": 12.5})
self.assertEqual(
FakeMotionControl.last_instance.image_url,
"https://tmpfiles.org/dl/img/ref.png",
)
self.assertEqual(
FakeMotionControl.last_instance.video_url,
"https://tmpfiles.org/dl/123/reference.mp4",
)
self.assertEqual(FakeMotionControl.last_instance.duration, "12.5")
self.assertEqual(FakeMotionControl.last_instance.character_orientation, "video")
self.assertEqual(FakeMotionControl.last_instance.keep_original_sound, "yes")
self.assertEqual(FakeMotionControl.last_instance.seen_client, sentinel_client)
def test_motion_control_uploads_video_input(self):
sentinel_client = object()
@contextmanager
def fake_runtime_client():
yield sentinel_client
class FakeMotionControl:
last_instance = None
def __init__(self):
FakeMotionControl.last_instance = self
def run(self, client):
self.seen_client = client
return SimpleNamespace(
final_unit_deduction=1.0,
task_result=SimpleNamespace(
videos=[SimpleNamespace(url="https://example.com/video.mp4", id="video-123")]
),
)
class FakeVideo:
def get_duration(self):
return 7.0
fake_video = FakeVideo()
with mock.patch.object(kling_nodes, "_runtime_client", fake_runtime_client):
with mock.patch.object(kling_nodes, "MotionControl", FakeMotionControl):
with mock.patch.object(kling_nodes, "_upload_image_reference", return_value="https://tmpfiles.org/dl/img/ref.png"):
with mock.patch.object(
kling_nodes,
"_upload_video_reference",
return_value="https://tmpfiles.org/dl/456/reference.mp4",
) as upload_mock:
url, video_id = kling_nodes.MotionControlNode().generate(
model_name="kling-v2-6",
reference_image=object(),
reference_video_input=fake_video,
)
self.assertEqual(url, "https://example.com/video.mp4")
self.assertEqual(video_id, "video-123")
upload_mock.assert_called_once_with(fake_video)
self.assertEqual(
FakeMotionControl.last_instance.video_url,
"https://tmpfiles.org/dl/456/reference.mp4",
)
self.assertEqual(
FakeMotionControl.last_instance.image_url,
"https://tmpfiles.org/dl/img/ref.png",
)
self.assertEqual(FakeMotionControl.last_instance.duration, "7")
def test_motion_control_accepts_alternate_video_url_fields(self):
sentinel_client = object()
@contextmanager
def fake_runtime_client():
yield sentinel_client
class FakeMotionControl:
last_instance = None
def __init__(self):
FakeMotionControl.last_instance = self
def run(self, client):
self.seen_client = client
return SimpleNamespace(
final_unit_deduction=1.0,
task_status="succeed",
task_result=SimpleNamespace(
videos=[SimpleNamespace(video_url="https://example.com/video-from-alias.mp4", id="video-456")]
),
)
class FakeVideo:
def get_duration(self):
return 7.0
with mock.patch.object(kling_nodes, "_runtime_client", fake_runtime_client):
with mock.patch.object(kling_nodes, "MotionControl", FakeMotionControl):
with mock.patch.object(kling_nodes, "_upload_image_reference", return_value="https://tmpfiles.org/dl/img/ref.png"):
with mock.patch.object(
kling_nodes,
"_upload_video_reference",
return_value="https://tmpfiles.org/dl/456/reference.mp4",
):
url, video_id = kling_nodes.MotionControlNode().generate(
model_name="kling-v2-6",
reference_image=object(),
reference_video_input=FakeVideo(),
)
self.assertEqual(url, "https://example.com/video-from-alias.mp4")
self.assertEqual(video_id, "video-456")
def test_motion_control_raises_task_failure_details(self):
sentinel_client = object()
@contextmanager
def fake_runtime_client():
yield sentinel_client
class FakeMotionControl:
def run(self, client):
return SimpleNamespace(
final_unit_deduction=0.0,
task_status="failed",
task_status_msg="reference video duration exceeds backend limit",
task_result=SimpleNamespace(videos=[]),
)
class FakeVideo:
def get_duration(self):
return 12.0
with mock.patch.object(kling_nodes, "_runtime_client", fake_runtime_client):
with mock.patch.object(kling_nodes, "MotionControl", FakeMotionControl):
with mock.patch.object(kling_nodes, "_upload_image_reference", return_value="https://tmpfiles.org/dl/img/ref.png"):
with mock.patch.object(
kling_nodes,
"_upload_video_reference",
return_value="https://tmpfiles.org/dl/456/reference.mp4",
):
with self.assertRaisesRegex(
ValueError,
"task_status='failed'.*reference video duration exceeds backend limit",
):
kling_nodes.MotionControlNode().generate(
model_name="kling-v2-6",
reference_image=object(),
reference_video_input=FakeVideo(),
)
def test_client_retries_transient_get_transport_error(self):
first_client = mock.Mock()
second_client = mock.Mock()
ok_response = mock.Mock()
ok_response.status_code = 200
ok_response.json.return_value = {"data": {"task_status": "submitted"}}
first_client.request.side_effect = httpx.RemoteProtocolError(
"Server disconnected without sending a response."
)
second_client.request.return_value = ok_response
with mock.patch.object(kling_client.jwt, "encode", return_value="token"):
with mock.patch.object(
kling_client.httpx,
"Client",
side_effect=[first_client, second_client],
):
client = kling_client.Client(
access_key="ak",
secret_key="sk",
in_china=True,
timeout=30,
poll_interval=0.01,
)
result = client.request("GET", "/v1/videos/motion-control/task-123")
self.assertEqual(result, {"data": {"task_status": "submitted"}})
self.assertEqual(first_client.request.call_count, 1)
self.assertEqual(second_client.request.call_count, 1)
def test_client_retries_transient_post_connect_error(self):
first_client = mock.Mock()
second_client = mock.Mock()
ok_response = mock.Mock()
ok_response.status_code = 200
ok_response.json.return_value = {"data": {"task_id": "task-123"}}
first_client.request.side_effect = httpx.ConnectError(
"[SSL: UNEXPECTED_EOF_WHILE_READING] EOF occurred in violation of protocol"
)
second_client.request.return_value = ok_response
with mock.patch.object(kling_client.jwt, "encode", return_value="token"):
with mock.patch.object(
kling_client.httpx,
"Client",
side_effect=[first_client, second_client],
):
client = kling_client.Client(
access_key="ak",
secret_key="sk",
in_china=True,
timeout=30,
poll_interval=0.01,
)
result = client.request("POST", "/v1/videos/motion-control", json={"prompt": "test"})
self.assertEqual(result, {"data": {"task_id": "task-123"}})
self.assertEqual(first_client.request.call_count, 1)
self.assertEqual(second_client.request.call_count, 1)
def test_motion_control_accepts_direct_reference_video_url(self):
sentinel_client = object()
@contextmanager
def fake_runtime_client():
yield sentinel_client
class FakeMotionControl:
last_instance = None
def __init__(self):
FakeMotionControl.last_instance = self
def run(self, client):
self.seen_client = client
return SimpleNamespace(
final_unit_deduction=1.0,
task_result=SimpleNamespace(
videos=[SimpleNamespace(url="https://example.com/video.mp4", id="video-123")]
),
)
reference_video_url = "https://example.com/reference-motion.mp4"
with mock.patch.object(kling_nodes, "_runtime_client", fake_runtime_client):
with mock.patch.object(kling_nodes, "MotionControl", FakeMotionControl):
with mock.patch.object(
kling_nodes,
"_upload_image_reference",
return_value="https://tmpfiles.org/dl/img/ref.png",
):
url, video_id = kling_nodes.MotionControlNode().generate(
model_name="kling-v2-6",
reference_image=object(),
reference_video=reference_video_url,
duration="5",
)
self.assertEqual(url, "https://example.com/video.mp4")
self.assertEqual(video_id, "video-123")
self.assertEqual(FakeMotionControl.last_instance.video_url, reference_video_url)
self.assertIs(FakeMotionControl.last_instance.seen_client, sentinel_client)
def test_motion_control_auto_duration_requires_video_timing(self):
with self.assertRaisesRegex(
ValueError,
"duration='auto' requires",
):
with mock.patch.object(
kling_nodes,
"_upload_reference_video_frames",
return_value="https://tmpfiles.org/dl/789/reference.mp4",
):
with mock.patch.object(
kling_nodes,
"_upload_image_reference",
return_value="https://tmpfiles.org/dl/img/ref.png",
):
kling_nodes.MotionControlNode().generate(
model_name="kling-v2-6",
reference_image=object(),
reference_video_frames=object(),
)
def test_examples_are_client_free_and_valid_json(self):
examples_dir = REPO_ROOT / "examples"
example_files = sorted(examples_dir.glob("*.json"))
self.assertTrue(example_files)
for example_path in example_files:
content = example_path.read_text(encoding="utf-8")
data = json.loads(content)
self.assertIsInstance(data, dict)
self.assertNotIn("Comfyui-Kling-Wrapper Client", content)
self.assertNotIn("COMFYUI_KLING_WRAPPER_API_CLIENT", content)
self.assertNotIn('"tencent_vod"', content)
if example_path.name == "07_comfyui_kling_wrapper_motion_control_v26.json":
self.assertIn("\"type\": \"LoadVideo\"", content)
self.assertIn("\"reference_video_input\"", content)
self.assertNotIn("reference-motion.mp4", content)
if example_path.name == "18_comfyui_kling_wrapper_custom_voice_tts_preview.json":
node_by_type = {node["type"]: node for node in data["nodes"]}
self.assertIn("LoadAudio", node_by_type)
self.assertIn("Comfyui-Kling-Wrapper Custom Voice Create", node_by_type)
self.assertIn("Comfyui-Kling-Wrapper Preview Audio", node_by_type)
self.assertNotIn("Comfyui-Kling-Wrapper TTS", node_by_type)
custom_voice_node = node_by_type["Comfyui-Kling-Wrapper Custom Voice Create"]
audio_input = next(
node_input
for node_input in custom_voice_node["inputs"]
if node_input["name"] == "audio"
)
self.assertIsNotNone(audio_input["link"])
self.assertEqual(custom_voice_node["widgets_values"][1], "")
preview_audio_node = node_by_type["Comfyui-Kling-Wrapper Preview Audio"]
preview_audio_link = next(
node_input["link"]
for node_input in preview_audio_node["inputs"]
if node_input["name"] == "audio_url"
)
source_link = next(link for link in data["links"] if link[0] == preview_audio_link)
self.assertEqual(source_link[2], 2)
def test_tencent_extended_nodes_are_registered_without_provider_widget(self):
mappings = kling_package.NODE_CLASS_MAPPINGS
self.assertIn("Comfyui-Kling-Wrapper Multi Modal Video Edit", mappings)
self.assertIn("Comfyui-Kling-Wrapper Advanced Element Delete", mappings)
for node_class in (
kling_nodes.MultiImagesToVideoNode,
kling_nodes.MotionControlNode,
kling_nodes.AdvancedCustomElementCreateNode,
kling_nodes.AdvancedCustomElementQueryNode,
kling_nodes.CustomVoiceCreateNode,
kling_nodes.CustomVoiceQueryNode,
kling_nodes.MultiModalVideoEditNode,
):
input_types = node_class.INPUT_TYPES()
self.assertNotIn("provider", input_types.get("required", {}))
self.assertNotIn("provider", input_types.get("optional", {}))
def test_tencent_multi_image_motion_and_multimodal_payloads(self):
provider = TencentVodVideoProvider(
TencentVodVideoConfig(secret_id="sid", secret_key="skey", sub_app_id=123)
)
captured = []
def capture(payload):
captured.append(payload)
return {"TaskId": f"task-{len(captured)}"}
result = SimpleNamespace(url="u", video_id="v")
with mock.patch.object(provider, "_create_task", side_effect=capture):
with mock.patch.object(provider, "_wait_for_video", return_value=result):
provider.generate_multi_images(
kling_providers.MultiImagesToVideoRequest(
model_name="kling-v3",
prompt="demo",
mode="std",
duration="5",
image_urls=["https://a.png", "https://b.png"],
)
)
provider.generate_motion_control(
kling_providers.MotionControlRequest(
model_name="kling-v2-6",
image_url="https://image.png",
video_url="https://motion.mp4",
prompt="move",
mode="std",
duration="3",
keep_original_sound=False,
)
)
provider.generate_multimodal_edit(
kling_providers.MultiModalVideoEditRequest(
model_name="kling-v3-omni",
video_url="https://base.mp4",
prompt="replace the coat",
edit_mode="base",
mode="std",
duration="3",
image_urls=["https://reference.png"],
)
)
self.assertEqual(
[item["Usage"] for item in captured[0]["FileInfos"]],
["Reference", "Reference"],
)
self.assertEqual(captured[1]["SceneType"], "motion_control")
self.assertEqual(captured[1]["FileInfos"][0]["Usage"], "FirstFrame")
self.assertEqual(captured[1]["FileInfos"][1]["Usage"], "Reference")
self.assertEqual(captured[1]["FileInfos"][1]["ReferenceType"], "feature")
self.assertEqual(captured[1]["FileInfos"][1]["KeepOriginalSound"], "Disabled")
self.assertEqual(captured[2]["ModelVersion"], "3.0-Omni")
self.assertEqual(captured[2]["FileInfos"][0]["Usage"], "Reference")
self.assertEqual(captured[2]["FileInfos"][0]["ReferenceType"], "base")
self.assertEqual(captured[2]["FileInfos"][1]["Usage"], "Reference")
with self.assertRaisesRegex(ValueError, "supports duration 5 or 10"):
provider.generate_multi_images(
kling_providers.MultiImagesToVideoRequest(
model_name="kling-v3",
prompt="demo",
mode="std",
duration="3",
image_urls=["https://a.png", "https://b.png"],
)
)
def test_tencent_subject_create_query_delete_wire_mapping(self):
provider = TencentVodVideoProvider(
TencentVodVideoConfig(secret_id="sid", secret_key="skey", sub_app_id=123)
)
task = {
"TaskId": "task-element",
"Status": "FINISH",
"ErrCode": 0,
"ErrCodeExt": "",
"Output": {
"InfoList": [{
"ElementId": "element-123",
"ElementInfo": json.dumps({
"element_id": "element-123",
"element_name": "Demo",
}),
}]
},
}
with mock.patch.object(
provider,
"_request",
return_value={"TaskId": "task-element", "RequestId": "request-create"},
) as request_mock:
with mock.patch.object(provider, "_wait_for_task", return_value=task):
result = provider.create_element(
kling_providers.AdvancedElementCreateRequest(
element_name="Demo",
element_description="Demo subject",
reference_type="image_refer",
element_voice_id="voice-123",
element_image_list={
"frontal_image": "https://front.png",
"refer_images": [{"image_url": "https://side.png"}],
},
tag_list=[{"tag_id": "o_102"}],
)
)
action, payload = request_mock.call_args[0]
self.assertEqual(action, "CreateAigcAdvancedCustomElement")
self.assertIsInstance(payload["ElementImageList"], str)
self.assertIsInstance(payload["TagList"], str)
self.assertEqual(payload["ElementVoiceId"], "voice-123")
self.assertEqual(result.element_id, "element-123")
with mock.patch.object(provider, "_request", return_value={
"ElementSet": [{
"Id": "element-123",
"Name": "Demo",
"Description": "Demo subject",
"CreateTime": "2026-07-03T00:00:00Z",
}],
"TotalCount": 1,
"RequestId": "request-query",
}):
queried = provider.query_element("element_id", "element-123")
self.assertEqual(queried.element["element_name"], "Demo")
with mock.patch.object(
provider,
"_request",
return_value={"RequestId": "request-delete"},
) as delete_mock:
request_id = provider.delete_element("element-123")
self.assertEqual(delete_mock.call_args[0][0], "DeleteAigcAdvancedCustomElement")
self.assertEqual(request_id, "request-delete")
def test_tencent_custom_voice_create_and_query_parse_voice_info(self):
provider = TencentVodVideoProvider(
TencentVodVideoConfig(secret_id="sid", secret_key="skey", sub_app_id=123)
)
task = {
"TaskId": "task-voice",
"Status": "FINISH",
"ErrCode": 0,
"ErrCodeExt": "",
"Output": {
"InfoList": [{
"VoiceId": "voice-123",
"VoiceInfo": json.dumps({
"voice_id": "voice-123",
"voice_name": "Demo Voice",
"trial_url": "https://trial.mp3",
"status": "succeed",
}),
}]
},
}
with mock.patch.object(
provider,
"_request",
return_value={"TaskId": "task-voice", "RequestId": "request-voice"},
) as request_mock:
with mock.patch.object(provider, "_wait_for_task", return_value=task):
created = provider.create_custom_voice(
kling_providers.CustomVoiceCreateRequest(
voice_name="Demo Voice",
voice_url="https://voice.mp3",
)
)
action, payload = request_mock.call_args[0]
self.assertEqual(action, "CreateAigcCustomVoice")
self.assertEqual(payload["VoiceUrl"], "https://voice.mp3")
self.assertNotIn("VideoId", payload)
self.assertEqual(created.voice["trial_url"], "https://trial.mp3")
with mock.patch.object(provider, "_wait_for_task", return_value=task):
queried = provider.query_custom_voice("task-voice")
self.assertEqual(queried.voice["voice_id"], "voice-123")
def test_tencent_terminal_task_error_and_unknown_model_are_not_silent(self):
provider = TencentVodVideoProvider(
TencentVodVideoConfig(
secret_id="sid",
secret_key="skey",
sub_app_id=123,
poll_interval=0.01,
max_wait_seconds=30,
)
)
with mock.patch.object(provider, "_describe_task", return_value={
"AigcVideoTask": {
"TaskId": "task-failed",
"Status": "FINISH",
"ErrCode": 1001,
"ErrCodeExt": "InvalidParameterValue",
"Message": "bad input",
}
}):
with self.assertRaisesRegex(
RuntimeError,
"task-failed.*InvalidParameterValue.*bad input",
):
provider._wait_for_video("task-failed")
with self.assertRaisesRegex(ValueError, "Unsupported Tencent VOD Kling model"):
provider.generate_text2video(
kling_providers.Text2VideoRequest(
model_name="unknown-model",
prompt="demo",
)
)
def test_official_image2video_keeps_reference_images_as_base64(self):
seen = {}
class FakeImage2Video:
def run(self, client):
seen["image_list"] = self.image_list
return SimpleNamespace(
task_id="task-official",
task_status="succeed",
task_status_msg="",
final_unit_deduction=1,
task_result=SimpleNamespace(
videos=[SimpleNamespace(id="video-1", url="https://video.mp4")]
),
)
class DummyClient:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
provider = OfficialVideoProvider(
OfficialVideoConfig(access_key="ak", secret_key="sk")
)
with mock.patch("py.api.providers.official.video.Image2Video", FakeImage2Video):
with mock.patch.object(provider, "_make_client", return_value=DummyClient()):
provider.generate_image2video(
kling_providers.Image2VideoRequest(
model_name="kling-v3",
image_base64="primary-base64",
image_list=["reference-base64"],
)
)
self.assertEqual(seen["image_list"], ["reference-base64"])
def test_official_turbo_text2video_uses_new_task_api(self):
requests_seen = []
class DummyClient:
poll_interval = 0
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def request(self, method, path, **kwargs):
requests_seen.append((method, path, kwargs))
if method == "POST":
return {"code": 0, "data": {"id": "task-turbo"}}
return {
"code": 0,
"data": [
{
"id": "task-turbo",
"status": "succeeded",
"outputs": [
{
"type": "video",
"id": "video-turbo",
"url": "https://example.com/turbo.mp4",
}
],
"billing": [{"amount": "2.5"}],
}
],
}
provider = OfficialVideoProvider(
OfficialVideoConfig(access_key="", secret_key="", api_key="json-api-key")
)
with mock.patch.object(provider, "_make_client", return_value=DummyClient()):
with mock.patch("py.api.providers.official.turbo.time.sleep"):
result = provider.generate_text2video(
kling_providers.Text2VideoRequest(
model_name="kling-v3-turbo",
prompt="demo",
mode="pro",
duration="7",
aspect_ratio="9:16",
)
)
self.assertEqual(result.url, "https://example.com/turbo.mp4")
self.assertEqual(result.final_unit_deduction, 2.5)
self.assertEqual(requests_seen[0][1], "/text-to-video/kling-3.0-turbo")
self.assertEqual(
requests_seen[0][2]["json"],
{
"prompt": "demo",
"settings": {"resolution": "1080p", "duration": 7, "aspect_ratio": "9:16"},
"options": {"watermark_info": {"enabled": False}},
},
)
self.assertEqual(requests_seen[1][1], "/tasks")
self.assertEqual(requests_seen[1][2]["params"], {"task_ids": "task-turbo"})
def test_official_turbo_image2video_maps_4k_mode_to_1080p(self):
turbo = importlib.import_module("py.api.providers.official.turbo")
payload = turbo.build_image2video_payload(
kling_providers.Image2VideoRequest(
model_name="kling-v3-turbo",
prompt="animate this",
image_base64="base64-image",
mode="4k",
duration="15",
)
)
self.assertEqual(payload["settings"], {"resolution": "1080p", "duration": 15})
self.assertEqual(
payload["contents"],
[
{"type": "prompt", "text": "animate this"},
{"type": "first_frame", "url": "base64-image"},
],
)
def test_official_turbo_rejects_separate_negative_prompt(self):
provider = OfficialVideoProvider(
OfficialVideoConfig(access_key="", secret_key="", api_key="json-api-key")
)
with self.assertRaisesRegex(ValueError, "negative_prompt"):
provider.generate_text2video(
kling_providers.Text2VideoRequest(
model_name="kling-v3-turbo",
prompt="demo",
negative_prompt="no blur",
mode="std",
duration="5",
)
)
if __name__ == "__main__":
unittest.main()