From dfc62cc6c1b6052ea88252db1eb9471d6476b5bc Mon Sep 17 00:00:00 2001 From: magicwang1111 <131337141+magicwang1111@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:50:39 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81V3omni=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 17 ++- examples/README.md | 5 +- py/__init__.py | 3 +- py/api/capabilities.py | 14 ++- py/nodes.py | 126 ++++++++++++++++++-- tests/test_v3_omni_visibility.py | 193 +++++++++++++++++++++++++++++++ 6 files changed, 338 insertions(+), 20 deletions(-) create mode 100644 tests/test_v3_omni_visibility.py diff --git a/README.md b/README.md index 114553c..8d9ace8 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ It bundles image generation, text-to-video, image-to-video, multi-image video, a ## Current model visibility -The plugin keeps the visible model dropdowns aligned with models verified on the active endpoint. +The plugin keeps the visible model dropdowns aligned with models confirmed for the active integration paths. Visible image generation models: @@ -34,6 +34,7 @@ Visible text-to-video models: - `kling-v2-5-turbo` - `kling-v2-6` - `kling-v3` +- `kling-v3-omni` Visible image-to-video models: @@ -46,26 +47,29 @@ Visible image-to-video models: - `kling-v2-5-turbo` - `kling-v2-6` - `kling-v3` +- `kling-v3-omni` Visible multi-image video models: - `kling-v2-1` - `kling-v2-5-turbo` - `kling-v3` +- `kling-v3-omni` Visible video generation modes: - `std` - `pro` -- `4k` (`kling-v3` text-to-video, image-to-video, and multi-image video only; the API uses `mode: "4k"`) +- `4k` (`kling-v3` and `kling-v3-omni` text-to-video, image-to-video, and multi-image video; the API uses `mode: "4k"`) -Documented newer models such as `kling-image-o1`, `kling-video-o1`, and `kling-v3-omni` still exist in the internal capability map, but are hidden from the UI until live endpoint support is confirmed for normal accounts. +Documented newer models such as `kling-image-o1` and `kling-video-o1` still exist in the internal capability map, but are hidden from the UI until live endpoint support is confirmed for normal accounts. ## Important limitations - Native 4K video output is exposed through the video `mode` dropdown. Models that do not support `mode=4k` will be rejected by the node capability checks before submission. - Native preset and cloned voices are available for `kling-v2-6`; `Image2Video.custom_voice_id` sends cloned voice control through `voice_list`. The active API rejects this field for `kling-v3`. -- `kling-v3-omni` and `kling-video-o1` remain hidden until live endpoint support is confirmed for normal accounts. +- `kling-v3-omni` is visible for text-to-video, image-to-video, and multi-image video comparison. It can combine `voice_list`/`custom_voice_id` with `element_list`; reference-video requests remain guarded because native audio and `mode=4k` are rejected when `reference_video` is provided. +- `kling-video-o1` remains hidden until live endpoint support is confirmed for normal accounts. - `Advanced Element Create` requires 1 frontal subject image plus 1-3 additional reference images of the same subject. Background images do not count as reference images. - `Advanced Element Create` also enforces `element_name <= 20` characters and `element_description <= 100` characters. - If you need both strong subject consistency and voiced output, the reliable flow on the current endpoint is usually: generate the bound subject video first, then add speech with `Lip Sync`, `TextToAudio`, or `Video2Audio`. @@ -150,6 +154,7 @@ Python dependencies are listed in [requirements.txt](./requirements.txt). - `Comfyui-Kling-Wrapper TTS` - `Comfyui-Kling-Wrapper Custom Voice Create` - `Comfyui-Kling-Wrapper Custom Voice Query` +- `Comfyui-Kling-Wrapper Custom Voice List Builder` - `Comfyui-Kling-Wrapper Video2Audio` - `Comfyui-Kling-Wrapper Advanced Element Create` - `Comfyui-Kling-Wrapper Advanced Element Query` @@ -219,7 +224,7 @@ When `duration` is set to `auto`, the node tries to match the uploaded reference ## Voice presets -`Text2Video` and `Image2Video` expose a `voice_preset` dropdown. `Image2Video` additionally accepts a connected `custom_voice_id`. +`Text2Video` and `Image2Video` expose a `voice_preset` dropdown. `Image2Video` additionally accepts a connected `custom_voice_id` for one custom voice, or a connected `custom_voice_list` from `Custom Voice List Builder` for multiple custom voices. The selected preset is converted to: @@ -229,6 +234,8 @@ The selected preset is converted to: These presets are mainly intended for models that support native audio generation on the active endpoint, especially `kling-v2-6`. +For multiple custom voices, connect one or more `Custom Voice Create.voice_id` outputs to `Custom Voice List Builder`, then connect its `custom_voice_list` output to `Image2Video.custom_voice_list`. Set `sound=on` and reference each voice in order with prompt markers such as `<<>>` and `<<>>`. `custom_voice_list`, `custom_voice_id`, and `voice_preset` are mutually exclusive. + For official or manually supplied preset voice IDs, use the dedicated `TTS` node. It calls `/v1/audio/tts` and returns an `audio_url` that can be saved or previewed with `Preview Audio`. ## Examples diff --git a/examples/README.md b/examples/README.md index 2e755b6..53180ce 100644 --- a/examples/README.md +++ b/examples/README.md @@ -11,17 +11,18 @@ This directory contains importable ComfyUI workflow JSON examples and small Pyth - Replace placeholder filenames such as `example_portrait.png`, `example_subject_front.png`, `example_subject_ref_1.png`, `example_cloth.png`, `example_scene.png`, `example_lipsync_source.mp4`, `example_lipsync_audio.wav`, and `example_voice_reference.mp3` with files that exist in your ComfyUI input directory. - Replace placeholder URLs such as `https://example.com/reference-motion.mp4` with real URLs when needed. - Native preset and cloned voices are available for `kling-v2-6`; the active API rejects `voice_list` for `kling-v3`. -- Newer documented models such as `kling-video-o1` and `kling-v3-omni` are intentionally hidden from the visible dropdowns until live endpoint support is confirmed. +- `kling-v3-omni` is visible in the video generation dropdowns for comparison with `kling-v3`; `kling-video-o1` remains hidden until live endpoint support is confirmed. ## Important workflow notes - `05_comfyui_kling_wrapper_text2video_v26_sound.json` is the native audio example and is built around `kling-v2-6`. - `06_comfyui_kling_wrapper_advanced_element_subject_to_image2video.json` requires 1 frontal portrait plus 1-3 additional photos of the same subject. Background or scene images do not count as advanced-element reference images. - If you need both subject binding and speech, generate the bound video first and then add speech with the lip-sync or audio nodes. +- `kling-v3-omni` can combine `custom_voice_id` or `custom_voice_list` with `element_list`; older models still keep stricter local guards. - `Motion Control` accepts either a direct http(s) reference-video URL or uploaded/local video workflow inputs. For local media, use a video loader node that provides either `VIDEO` or `reference_video_frames` plus `reference_video_info`. Set `duration` to `auto` if you want it to match the uploaded motion reference length. - `Lip Sync` now calls `identify-face` and `advanced-lip-sync`; leave `face_id` empty to use the first detected face. - `Avatar` accepts one and only one of `AUDIO`, `audio_id`, `audio_file`, or `audio_url`. -- `Image2Video.custom_voice_id` is supported by `kling-v2-6`; it requires `sound=on` and the `<<>>` marker in the prompt. +- `Image2Video.custom_voice_id` is the single-custom-voice path. For multiple voices, connect `Custom Voice List Builder.custom_voice_list` to `Image2Video.custom_voice_list`, set `sound=on`, and use ordered markers such as `<<>>` and `<<>>`. ## Included files diff --git a/py/__init__.py b/py/__init__.py index 51076ed..a4256c6 100644 --- a/py/__init__.py +++ b/py/__init__.py @@ -1,5 +1,5 @@ from .api.capabilities import NODE_PREFIX -from .nodes import AdvancedCustomElementCreateNode, AdvancedCustomElementDeleteNode, AdvancedCustomElementQueryNode, AvatarNode, EffectNode, ElementListBuilderNode, \ +from .nodes import AdvancedCustomElementCreateNode, AdvancedCustomElementDeleteNode, AdvancedCustomElementQueryNode, AvatarNode, CustomVoiceListBuilderNode, EffectNode, ElementListBuilderNode, \ Image2VideoNode, ImageExpanderNode, ImageGeneratorNode, KolorsVirtualTryOnNode, LipSyncAudioInputNode, \ LipSyncNode, LipSyncTextInputNode, MotionControlNode, MultiImagesToVideoNode, MultiModalVideoEditNode, PreviewAudio, PreviewVideo, Text2VideoNode, \ TextToAudioNode, Video2AudioNode, VideoExtendNode, CustomVoiceCreateNode, CustomVoiceQueryNode, TTSNode @@ -16,6 +16,7 @@ NODE_CLASS_MAPPINGS = { _node_name("TTS"): TTSNode, _node_name("Custom Voice Create"): CustomVoiceCreateNode, _node_name("Custom Voice Query"): CustomVoiceQueryNode, + _node_name("Custom Voice List Builder"): CustomVoiceListBuilderNode, _node_name("Avatar"): AvatarNode, _node_name("Video2Audio"): Video2AudioNode, _node_name("Text2Video"): Text2VideoNode, diff --git a/py/api/capabilities.py b/py/api/capabilities.py index e282a64..cd4b19d 100644 --- a/py/api/capabilities.py +++ b/py/api/capabilities.py @@ -5,6 +5,7 @@ CLIENT_TYPE = "COMFYUI_KLING_WRAPPER_API_CLIENT" LIPSYNC_INPUT_TYPE = "COMFYUI_KLING_WRAPPER_LIPSYNC_INPUT" ELEMENT_TYPE = "COMFYUI_KLING_WRAPPER_ELEMENT" ELEMENT_LIST_TYPE = "COMFYUI_KLING_WRAPPER_ELEMENT_LIST" +CUSTOM_VOICE_LIST_TYPE = "COMFYUI_KLING_WRAPPER_CUSTOM_VOICE_LIST" DEFAULT_VIDEO_ASPECT_RATIOS = ["16:9", "9:16", "1:1"] EXTENDED_IMAGE_ASPECT_RATIOS = ["16:9", "9:16", "1:1", "4:3", "3:4", "3:2", "2:3", "21:9", "auto"] @@ -248,8 +249,8 @@ VIDEO_MODEL_CAPABILITIES = { "supports_image_list": True, "supports_element_list": True, "supports_reference_video": True, - "supports_sound": False, - "supports_voice_list": False, + "supports_sound": True, + "supports_voice_list": True, "supports_negative_prompt": False, "supports_cfg_scale": False, "shot_types": ["single", "intelligence"], @@ -258,9 +259,9 @@ VIDEO_MODEL_CAPABILITIES = { } # ComfyUI input options are static, so we keep the visible dropdowns aligned -# with models verified on the active endpoint. Newer documented models may -# still exist in VIDEO_MODEL_CAPABILITIES for future use, but remain hidden -# until live account support is confirmed. +# with models confirmed for the active integration paths. Some documented +# models may still exist in VIDEO_MODEL_CAPABILITIES for future use, but remain +# hidden until endpoint support is confirmed. IMAGE_GENERATION_MODELS = [ "kling-v1", "kling-v1-5", @@ -278,6 +279,7 @@ TEXT_TO_VIDEO_MODELS = [ "kling-v2-5-turbo", "kling-v2-6", "kling-v3", + "kling-v3-omni", ] IMAGE_TO_VIDEO_MODELS = [ @@ -290,6 +292,7 @@ IMAGE_TO_VIDEO_MODELS = [ "kling-v2-5-turbo", "kling-v2-6", "kling-v3", + "kling-v3-omni", ] # Multi-image generation is routed through image2video with a primary `image` @@ -298,6 +301,7 @@ MULTI_IMAGE_TO_VIDEO_MODELS = [ "kling-v2-1", "kling-v2-5-turbo", "kling-v3", + "kling-v3-omni", ] diff --git a/py/nodes.py b/py/nodes.py index ec15ed4..dda93cd 100644 --- a/py/nodes.py +++ b/py/nodes.py @@ -2,11 +2,12 @@ from .api import AdvancedLipSync, Avatar, Client, FaceIdentify, ImageGenerator, Video2Audio, Text2Audio, Text2Video, CameraControl, CameraControlConfig, KolorsVurtualTryOn, VideoExtend, \ LipSyncInput, EffectInput, Effects, MultiImages2Video, MultiModelVideoEdit, AdvancedCustomElements, \ MotionControl, CustomVoiceCreate, CustomVoiceQuery, TTS -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, VIDEO_GENERATION_MODES, get_image_capability, get_video_capability, \ - validate_image_generation_request, validate_video_generation_request +from .api.capabilities import CUSTOM_VOICE_LIST_TYPE, DEFAULT_IMAGE_RESOLUTIONS, DEFAULT_MODES, \ + DEFAULT_VIDEO_ASPECT_RATIOS, DEFAULT_VIDEO_DURATIONS, ELEMENT_LIST_TYPE, ELEMENT_TYPE, \ + EXTENDED_IMAGE_ASPECT_RATIOS, IMAGE_GENERATION_MODELS, IMAGE_TO_VIDEO_MODELS, LIPSYNC_INPUT_TYPE, \ + MULTI_IMAGE_TO_VIDEO_MODELS, NODE_CATEGORY, NODE_PREFIX, SHOT_TYPES, SOUND_OPTIONS, TEXT_TO_VIDEO_MODELS, \ + VIDEO_GENERATION_MODES, get_image_capability, get_video_capability, validate_image_generation_request, \ + validate_video_generation_request from .api.exceptions import KLingAPIError from .api.providers import ( OFFICIAL_PROVIDER, @@ -616,6 +617,61 @@ def _build_voice_list_from_preset(voice_preset): return [{"voice_id": voice_id}] +def _normalize_voice_reference(voice): + if voice is None: + return None + if isinstance(voice, dict): + voice_id = str(voice.get("voice_id") or voice.get("id") or "").strip() + if not voice_id: + raise ValueError("Voice payload is missing voice_id.") + return {"voice_id": voice_id} + if isinstance(voice, str): + voice_id = voice.strip() + if not voice_id: + return None + return {"voice_id": voice_id} + raise ValueError("Voice must be a dict or string.") + + +def _normalize_voice_list(voices): + if voices is None: + return None + if isinstance(voices, str): + cleaned = voices.strip() + if not cleaned: + return None + if cleaned[0] in "[{": + decoded = _parse_json_input(cleaned, "custom_voice_list") + voices = decoded + else: + voices = [line.strip() for line in cleaned.replace(",", "\n").splitlines()] + if isinstance(voices, dict): + voices = [voices] + normalized = [] + for voice in voices: + payload = _normalize_voice_reference(voice) + if payload: + normalized.append(payload) + return normalized or None + + +def _validate_voice_prompt_markers(prompt, voice_list): + if not voice_list: + return + prompt_text = str(prompt or "") + missing_markers = [ + f"<<>>" + for index in range(1, len(voice_list) + 1) + if f"<<>>" not in prompt_text + ] + if missing_markers: + raise ValueError( + "custom_voice_list requires prompt markers: " + + ", ".join(missing_markers) + + "." + ) + + def _apply_prompt_controls(target, capability, prompt=None, negative_prompt=None, cfg_scale=None): cleaned_prompt = _clean_optional_text(prompt) cleaned_negative_prompt = _clean_optional_text(negative_prompt) @@ -1960,6 +2016,7 @@ class Image2VideoNode: "sound": (SOUND_OPTIONS,), "voice_preset": (VOICE_PRESET_OPTIONS, {"default": DEFAULT_VOICE_PRESET}), "custom_voice_id": ("STRING", {"forceInput": True}), + "custom_voice_list": (CUSTOM_VOICE_LIST_TYPE,), "shot_type": (SHOT_TYPES,), "image_list": ("IMAGE",), "element_list": (ELEMENT_LIST_TYPE,), @@ -1992,6 +2049,7 @@ class Image2VideoNode: sound="off", voice_preset=DEFAULT_VOICE_PRESET, custom_voice_id="", + custom_voice_list=None, shot_type="single", image_list=None, element_list=None, @@ -2001,16 +2059,28 @@ class Image2VideoNode: mode = _normalize_requested_mode(model, capability, mode) normalized_element_list = _normalize_element_list(element_list) preset_voice_list = _build_voice_list_from_preset(voice_preset) + normalized_custom_voice_list = _normalize_voice_list(custom_voice_list) custom_voice_id = str(custom_voice_id or "").strip() if custom_voice_id and preset_voice_list: raise ValueError("Use custom_voice_id or voice_preset, not both.") + if normalized_custom_voice_list and (custom_voice_id or preset_voice_list): + raise ValueError("Use custom_voice_list, custom_voice_id, or voice_preset, not more than one.") if custom_voice_id and sound != "on": raise ValueError("custom_voice_id requires sound='on'.") + if normalized_custom_voice_list and sound != "on": + raise ValueError("custom_voice_list requires sound='on'.") if custom_voice_id and "<<>>" not in str(prompt or ""): raise ValueError("custom_voice_id requires the prompt marker <<>>.") - if custom_voice_id and normalized_element_list: + _validate_voice_prompt_markers(prompt, normalized_custom_voice_list) + if custom_voice_id and normalized_element_list and model != "kling-v3-omni": raise ValueError("custom_voice_id cannot be used together with element_list.") - parsed_voice_list = [{"voice_id": custom_voice_id}] if custom_voice_id else preset_voice_list + if normalized_custom_voice_list and normalized_element_list and model != "kling-v3-omni": + raise ValueError("custom_voice_list cannot be used together with element_list.") + parsed_voice_list = ( + normalized_custom_voice_list + or ([{"voice_id": custom_voice_id}] if custom_voice_id else None) + or preset_voice_list + ) encoded_image_list = _image_batch_to_base64_list(image_list) has_reference_video = isinstance(reference_video, str) and bool(reference_video.strip()) has_camera_control = camera_control_type not in (None, "None") @@ -3439,6 +3509,48 @@ class ElementListBuilderNode: ) +class CustomVoiceListBuilderNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "voice_1": ("STRING", {"forceInput": True}), + }, + "optional": { + "voice_2": ("STRING", {"forceInput": True}), + "voice_3": ("STRING", {"forceInput": True}), + "voice_4": ("STRING", {"forceInput": True}), + "extra_voice_ids": ("STRING", {"multiline": True, "default": ""}), + } + } + + RETURN_TYPES = (CUSTOM_VOICE_LIST_TYPE, "STRING") + RETURN_NAMES = ("custom_voice_list", "custom_voice_list_json") + FUNCTION = "build" + OUTPUT_NODE = False + CATEGORY = NODE_CATEGORY + + def build(self, voice_1, voice_2=None, voice_3=None, voice_4=None, extra_voice_ids=""): + voice_refs = [] + for voice_id in (voice_1, voice_2, voice_3, voice_4): + if voice_id is not None: + voice_refs.append(voice_id) + + for raw_line in (extra_voice_ids or "").replace(",", "\n").splitlines(): + voice_id = raw_line.strip() + if voice_id: + voice_refs.append(voice_id) + + normalized = _normalize_voice_list(voice_refs) + if not normalized: + raise ValueError("At least one voice_id is required.") + + return ( + normalized, + json.dumps(normalized, ensure_ascii=False), + ) + + class MotionControlNode: @classmethod def INPUT_TYPES(cls): diff --git a/tests/test_v3_omni_visibility.py b/tests/test_v3_omni_visibility.py new file mode 100644 index 0000000..bf44e4b --- /dev/null +++ b/tests/test_v3_omni_visibility.py @@ -0,0 +1,193 @@ +import importlib +import sys +import types +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + + +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)) + + +def _install_import_stubs(): + if "torch" not in sys.modules: + sys.modules["torch"] = types.SimpleNamespace() + if "comfy_extras.nodes_audio" not in sys.modules: + audio_module = types.ModuleType("comfy_extras.nodes_audio") + audio_module.LoadAudio = object + sys.modules["comfy_extras.nodes_audio"] = audio_module + + +class V3OmniVisibilityTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + _install_import_stubs() + cls.capabilities = importlib.import_module("py.api.capabilities") + cls.nodes = importlib.import_module("py.nodes") + cls.tencent_video = importlib.import_module("py.api.providers.tencent_vod.video") + + def test_v3_omni_is_visible_for_video_comparison_nodes(self): + for model_list_name in ( + "TEXT_TO_VIDEO_MODELS", + "IMAGE_TO_VIDEO_MODELS", + "MULTI_IMAGE_TO_VIDEO_MODELS", + ): + with self.subTest(model_list_name=model_list_name): + self.assertIn( + "kling-v3-omni", + getattr(self.capabilities, model_list_name), + ) + + def test_tencent_vod_v3_omni_model_version_matches_docs(self): + self.assertEqual( + self.tencent_video.TencentVodVideoProvider._resolve_model_version("kling-v3-omni"), + "3.0-Omni", + ) + + def test_v3_omni_reference_video_capability_stays_guarded(self): + capability = self.capabilities.validate_video_generation_request( + task_name="image2video", + model_name="kling-v3-omni", + mode="pro", + duration="5", + has_reference_video=True, + ) + + self.assertTrue(capability["supports_reference_video"]) + self.assertTrue(capability["supports_sound"]) + self.assertTrue(capability["supports_voice_list"]) + + def test_v3_omni_allows_custom_voice_with_element_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-omni", + final_unit_deduction=None, + ) + + def close(self): + pass + + with mock.patch.object(self.nodes, "_create_video_provider", return_value=FakeProvider()): + url, video_id = self.nodes.Image2VideoNode().generate( + model="kling-v3-omni", + provider="official", + prompt='The subject <<>> says: "Hello."', + mode="pro", + duration="5", + sound="on", + custom_voice_id="voice-custom", + element_list=[{"element_id": "element-123"}], + ) + + self.assertEqual(url, "https://example.com/video.mp4") + self.assertEqual(video_id, "video-omni") + self.assertEqual(captured_requests[0].voice_list, [{"voice_id": "voice-custom"}]) + self.assertEqual(captured_requests[0].element_list, [{"element_id": "element-123"}]) + + def test_custom_voice_list_builder_collects_multiple_voice_ids(self): + voice_list, voice_list_json = self.nodes.CustomVoiceListBuilderNode().build( + voice_1="voice-a", + voice_2="voice-b", + extra_voice_ids="voice-c\nvoice-d", + ) + + self.assertEqual( + voice_list, + [ + {"voice_id": "voice-a"}, + {"voice_id": "voice-b"}, + {"voice_id": "voice-c"}, + {"voice_id": "voice-d"}, + ], + ) + self.assertIn("voice-d", voice_list_json) + + def test_custom_voice_list_builder_is_registered(self): + mappings = importlib.import_module("py").NODE_CLASS_MAPPINGS + self.assertIn("Comfyui-Kling-Wrapper Custom Voice List Builder", mappings) + + def test_v3_omni_custom_voice_list_sends_multiple_voices(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-omni", + final_unit_deduction=None, + ) + + def close(self): + pass + + custom_voice_list = [ + {"voice_id": "voice-a"}, + {"voice_id": "voice-b"}, + ] + with mock.patch.object(self.nodes, "_create_video_provider", return_value=FakeProvider()): + self.nodes.Image2VideoNode().generate( + model="kling-v3-omni", + provider="official", + prompt='<<>> says hello. <<>> replies.', + mode="pro", + duration="5", + sound="on", + custom_voice_list=custom_voice_list, + element_list=[{"element_id": "element-123"}], + ) + + self.assertEqual(captured_requests[0].voice_list, custom_voice_list) + self.assertEqual(captured_requests[0].element_list, [{"element_id": "element-123"}]) + + def test_custom_voice_list_requires_matching_prompt_markers(self): + with self.assertRaisesRegex(ValueError, "<<>>"): + self.nodes.Image2VideoNode().generate( + model="kling-v3-omni", + provider="official", + prompt="<<>> says hello.", + mode="pro", + duration="5", + sound="on", + custom_voice_list=[{"voice_id": "voice-a"}, {"voice_id": "voice-b"}], + ) + + def test_older_custom_voice_models_still_reject_element_list(self): + with self.assertRaisesRegex(ValueError, "cannot be used together with element_list"): + self.nodes.Image2VideoNode().generate( + model="kling-v2-6", + provider="official", + prompt='The subject <<>> says: "Hello."', + mode="pro", + duration="5", + sound="on", + custom_voice_id="voice-custom", + element_list=[{"element_id": "element-123"}], + ) + + with self.assertRaisesRegex(ValueError, "custom_voice_list cannot be used together with element_list"): + self.nodes.Image2VideoNode().generate( + model="kling-v2-6", + provider="official", + prompt='<<>> says hello. <<>> replies.', + mode="pro", + duration="5", + sound="on", + custom_voice_list=[{"voice_id": "voice-a"}, {"voice_id": "voice-b"}], + element_list=[{"element_id": "element-123"}], + ) + + +if __name__ == "__main__": + unittest.main()