runapi-kling 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,29 @@
1
+ # Build artifacts
2
+ dist/
3
+ build/
4
+ *.egg-info/
5
+ *.egg
6
+
7
+ # Bytecode
8
+ __pycache__/
9
+ *.py[cod]
10
+
11
+ # Virtual environments
12
+ .venv/
13
+ venv/
14
+
15
+ # uv
16
+ uv.lock
17
+
18
+ # Test / type caches
19
+ .pytest_cache/
20
+ .mypy_cache/
21
+ .ruff_cache/
22
+ .coverage
23
+ htmlcov/
24
+
25
+ # IDE / OS
26
+ .idea/
27
+ .vscode/
28
+ *.swp
29
+ .DS_Store
@@ -0,0 +1,87 @@
1
+ Metadata-Version: 2.4
2
+ Name: runapi-kling
3
+ Version: 0.1.0
4
+ Summary: Kling video generation client for RunAPI
5
+ Project-URL: Homepage, https://runapi.ai/models/kling
6
+ Project-URL: Documentation, https://runapi.ai/docs#sdk-kling
7
+ Author-email: RunAPI <contact@runapi.ai>
8
+ License-Expression: Apache-2.0
9
+ Keywords: ai,image-to-video,kling,runapi,sdk,text-to-video
10
+ Requires-Python: >=3.9
11
+ Requires-Dist: runapi-core
12
+ Description-Content-Type: text/markdown
13
+
14
+ # Kling Python SDK for RunAPI
15
+
16
+ The Kling Python SDK is the language-specific package for Kling on RunAPI.
17
+ Use it for text-to-video, image-to-video, AI avatar, and motion control flows
18
+ when your application needs JSON request bodies, task status lookup, and
19
+ consistent RunAPI errors in Python.
20
+
21
+ For model details, use https://runapi.ai/models/kling; for API reference, use
22
+ https://runapi.ai/docs#kling; for SDK docs, use https://runapi.ai/docs#sdk-kling.
23
+
24
+ ## Install
25
+
26
+ ```bash
27
+ pip install runapi-kling
28
+ ```
29
+
30
+ ## Quick start
31
+
32
+ ```python
33
+ from runapi.kling import KlingClient
34
+
35
+ client = KlingClient() # reads RUNAPI_API_KEY, or pass api_key="sk-..."
36
+
37
+ task = client.text_to_video.create(
38
+ model="kling-3.0",
39
+ prompt="A cat walking through a garden",
40
+ )
41
+ status = client.text_to_video.get(task.id)
42
+
43
+ avatar = client.ai_avatar.create(
44
+ model="kling-ai-avatar-pro",
45
+ prompt="A friendly host greeting the audience",
46
+ source_image_url="https://example.com/portrait.jpg",
47
+ source_audio_url="https://example.com/voice.mp3",
48
+ )
49
+ ```
50
+
51
+ Use `create` to submit a task and return quickly, `get` to fetch the latest task
52
+ state, and `run` to create and poll until completion:
53
+
54
+ ```python
55
+ result = client.text_to_video.run(
56
+ model="kling-3.0",
57
+ prompt="A serene drone shot over a misty forest",
58
+ )
59
+ print(result.videos[0].url)
60
+ ```
61
+
62
+ In web request handlers, prefer `create` plus webhook or later `get` polling so a
63
+ worker is not held open.
64
+
65
+ RunAPI-generated file URLs are temporary. Download and store generated videos in
66
+ your own durable storage within 7 days; do not treat returned URLs as long-term
67
+ assets.
68
+
69
+ ## Language notes
70
+
71
+ Pass parameters as keyword arguments and catch the `runapi.kling` error classes
72
+ when building video jobs or scripts. The available resources are
73
+ `text_to_video`, `ai_avatar`, `image_to_video`, and `motion_control`. Keep
74
+ `RUNAPI_API_KEY` in the environment or your secret manager; never commit API keys
75
+ or callback secrets.
76
+
77
+ ## Links
78
+
79
+ - Model page: https://runapi.ai/models/kling
80
+ - SDK docs: https://runapi.ai/docs#sdk-kling
81
+ - Product docs: https://runapi.ai/docs#kling
82
+ - Pricing and rate limits: https://runapi.ai/models/kling
83
+ - Full catalog: https://runapi.ai/models
84
+
85
+ ## License
86
+
87
+ Licensed under the Apache License, Version 2.0.
@@ -0,0 +1,74 @@
1
+ # Kling Python SDK for RunAPI
2
+
3
+ The Kling Python SDK is the language-specific package for Kling on RunAPI.
4
+ Use it for text-to-video, image-to-video, AI avatar, and motion control flows
5
+ when your application needs JSON request bodies, task status lookup, and
6
+ consistent RunAPI errors in Python.
7
+
8
+ For model details, use https://runapi.ai/models/kling; for API reference, use
9
+ https://runapi.ai/docs#kling; for SDK docs, use https://runapi.ai/docs#sdk-kling.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pip install runapi-kling
15
+ ```
16
+
17
+ ## Quick start
18
+
19
+ ```python
20
+ from runapi.kling import KlingClient
21
+
22
+ client = KlingClient() # reads RUNAPI_API_KEY, or pass api_key="sk-..."
23
+
24
+ task = client.text_to_video.create(
25
+ model="kling-3.0",
26
+ prompt="A cat walking through a garden",
27
+ )
28
+ status = client.text_to_video.get(task.id)
29
+
30
+ avatar = client.ai_avatar.create(
31
+ model="kling-ai-avatar-pro",
32
+ prompt="A friendly host greeting the audience",
33
+ source_image_url="https://example.com/portrait.jpg",
34
+ source_audio_url="https://example.com/voice.mp3",
35
+ )
36
+ ```
37
+
38
+ Use `create` to submit a task and return quickly, `get` to fetch the latest task
39
+ state, and `run` to create and poll until completion:
40
+
41
+ ```python
42
+ result = client.text_to_video.run(
43
+ model="kling-3.0",
44
+ prompt="A serene drone shot over a misty forest",
45
+ )
46
+ print(result.videos[0].url)
47
+ ```
48
+
49
+ In web request handlers, prefer `create` plus webhook or later `get` polling so a
50
+ worker is not held open.
51
+
52
+ RunAPI-generated file URLs are temporary. Download and store generated videos in
53
+ your own durable storage within 7 days; do not treat returned URLs as long-term
54
+ assets.
55
+
56
+ ## Language notes
57
+
58
+ Pass parameters as keyword arguments and catch the `runapi.kling` error classes
59
+ when building video jobs or scripts. The available resources are
60
+ `text_to_video`, `ai_avatar`, `image_to_video`, and `motion_control`. Keep
61
+ `RUNAPI_API_KEY` in the environment or your secret manager; never commit API keys
62
+ or callback secrets.
63
+
64
+ ## Links
65
+
66
+ - Model page: https://runapi.ai/models/kling
67
+ - SDK docs: https://runapi.ai/docs#sdk-kling
68
+ - Product docs: https://runapi.ai/docs#kling
69
+ - Pricing and rate limits: https://runapi.ai/models/kling
70
+ - Full catalog: https://runapi.ai/models
71
+
72
+ ## License
73
+
74
+ Licensed under the Apache License, Version 2.0.
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "runapi-kling"
7
+ version = "0.1.0"
8
+ description = "Kling video generation client for RunAPI"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "Apache-2.0"
12
+ authors = [{ name = "RunAPI", email = "contact@runapi.ai" }]
13
+ keywords = ["runapi", "kling", "text-to-video", "image-to-video", "ai", "sdk"]
14
+ dependencies = ["runapi-core"]
15
+
16
+ [project.urls]
17
+ Homepage = "https://runapi.ai/models/kling"
18
+ Documentation = "https://runapi.ai/docs#sdk-kling"
19
+
20
+ [tool.hatch.build.targets.wheel]
21
+ packages = ["src/runapi"]
22
+
23
+ [tool.uv]
24
+ package = true
25
+
26
+ [tool.uv.sources]
27
+ runapi-core = { path = "../runapi-core", editable = true }
28
+
29
+ [dependency-groups]
30
+ dev = ["pytest>=8"]
@@ -0,0 +1,24 @@
1
+ """Kling client for RunAPI."""
2
+
3
+ from runapi.core import (
4
+ AuthenticationError,
5
+ InsufficientCreditsError,
6
+ NotFoundError,
7
+ RateLimitError,
8
+ TaskFailedError,
9
+ TaskTimeoutError,
10
+ ValidationError,
11
+ )
12
+
13
+ from .client import KlingClient
14
+
15
+ __all__ = [
16
+ "KlingClient",
17
+ "AuthenticationError",
18
+ "RateLimitError",
19
+ "InsufficientCreditsError",
20
+ "NotFoundError",
21
+ "ValidationError",
22
+ "TaskFailedError",
23
+ "TaskTimeoutError",
24
+ ]
@@ -0,0 +1,33 @@
1
+ """Kling client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Optional
6
+
7
+ from runapi.core import ClientOptions, HttpClient, resolve_api_key
8
+
9
+ from .resources.ai_avatar import AiAvatar
10
+ from .resources.image_to_video import ImageToVideo
11
+ from .resources.motion_control import MotionControl
12
+ from .resources.text_to_video import TextToVideo
13
+
14
+
15
+ class KlingClient:
16
+ """Kling video generation client.
17
+
18
+ Example::
19
+
20
+ client = KlingClient(api_key="sk-...")
21
+ result = client.text_to_video.run(
22
+ model="kling-3.0", prompt="A cat walking through a garden"
23
+ )
24
+ """
25
+
26
+ def __init__(self, api_key: Optional[str] = None, **options: Any) -> None:
27
+ resolved_api_key = resolve_api_key(api_key)
28
+ client_options = ClientOptions(api_key=resolved_api_key, **options)
29
+ http = client_options.http_client or HttpClient(client_options)
30
+ self.text_to_video = TextToVideo(http)
31
+ self.ai_avatar = AiAvatar(http)
32
+ self.image_to_video = ImageToVideo(http)
33
+ self.motion_control = MotionControl(http)
File without changes
@@ -0,0 +1,6 @@
1
+ from .ai_avatar import AiAvatar
2
+ from .image_to_video import ImageToVideo
3
+ from .motion_control import MotionControl
4
+ from .text_to_video import TextToVideo
5
+
6
+ __all__ = ["TextToVideo", "AiAvatar", "ImageToVideo", "MotionControl"]
@@ -0,0 +1,70 @@
1
+ """Kling AI avatar resource."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict
6
+
7
+ from runapi.core import Resource, ValidationError
8
+
9
+ from ..types import AI_AVATAR_MODELS, AiAvatarResponse, CompletedAiAvatarResponse
10
+
11
+
12
+ class AiAvatar(Resource):
13
+ """Generate talking avatar videos from images and audio."""
14
+
15
+ ENDPOINT = "/api/v1/kling/ai_avatar"
16
+
17
+ RESPONSE_CLASS = AiAvatarResponse
18
+ COMPLETED_RESPONSE_CLASS = CompletedAiAvatarResponse
19
+
20
+ def run(self, **params: Any) -> Any:
21
+ """Generate an AI avatar video and poll until it completes.
22
+
23
+ Args:
24
+ **params: AI avatar parameters (model, source_image_url, source_audio_url, prompt, ...).
25
+
26
+ Returns:
27
+ The completed task with videos.
28
+ """
29
+ task = self.create(**params)
30
+ return self._poll_until_complete(lambda: self.get(task.id))
31
+
32
+ def create(self, **params: Any) -> Any:
33
+ """Create an AI avatar task and return immediately with an ``id``.
34
+
35
+ Args:
36
+ **params: AI avatar parameters (model, source_image_url, source_audio_url, prompt, ...).
37
+
38
+ Returns:
39
+ The task creation result with an id.
40
+ """
41
+ compacted = self._compact_params(params)
42
+ self._validate_params(compacted)
43
+ return self._request("post", self.ENDPOINT, body=compacted)
44
+
45
+ def get(self, id: str) -> Any:
46
+ """Fetch the current status of an AI avatar task.
47
+
48
+ Args:
49
+ id: The task id.
50
+
51
+ Returns:
52
+ The current task status.
53
+ """
54
+ return self._request("get", f"{self.ENDPOINT}/{id}")
55
+
56
+ def _validate_params(self, params: Dict[str, Any]) -> None:
57
+ model = params.get("model")
58
+ if not model:
59
+ raise ValidationError("model is required")
60
+ if model not in AI_AVATAR_MODELS:
61
+ raise ValidationError(
62
+ f"Invalid model: {model}. Must be one of: {', '.join(AI_AVATAR_MODELS)}"
63
+ )
64
+
65
+ if not params.get("source_image_url"):
66
+ raise ValidationError("source_image_url is required")
67
+ if not params.get("source_audio_url"):
68
+ raise ValidationError("source_audio_url is required")
69
+ if not params.get("prompt"):
70
+ raise ValidationError("prompt is required")
@@ -0,0 +1,90 @@
1
+ """Kling image-to-video resource."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict
6
+
7
+ from runapi.core import Resource, ValidationError
8
+
9
+ from ..types import (
10
+ FIXED_DURATIONS,
11
+ IMAGE_TO_VIDEO_MODELS,
12
+ CompletedImageToVideoResponse,
13
+ ImageToVideoResponse,
14
+ )
15
+
16
+
17
+ class ImageToVideo(Resource):
18
+ """Generate videos from an input image with Kling models."""
19
+
20
+ ENDPOINT = "/api/v1/kling/image_to_video"
21
+
22
+ RESPONSE_CLASS = ImageToVideoResponse
23
+ COMPLETED_RESPONSE_CLASS = CompletedImageToVideoResponse
24
+
25
+ def run(self, **params: Any) -> Any:
26
+ """Generate a video from an image and poll until it completes.
27
+
28
+ Args:
29
+ **params: Image-to-video parameters (model, prompt, first_frame_image_url, ...).
30
+
31
+ Returns:
32
+ The completed task with videos.
33
+ """
34
+ task = self.create(**params)
35
+ return self._poll_until_complete(lambda: self.get(task.id))
36
+
37
+ def create(self, **params: Any) -> Any:
38
+ """Create an image-to-video task and return immediately with an ``id``.
39
+
40
+ Args:
41
+ **params: Image-to-video parameters (model, prompt, first_frame_image_url, ...).
42
+
43
+ Returns:
44
+ The task creation result with an id.
45
+ """
46
+ compacted = self._compact_params(params)
47
+ self._validate_params(compacted)
48
+ return self._request("post", self.ENDPOINT, body=compacted)
49
+
50
+ def get(self, id: str) -> Any:
51
+ """Fetch the current status of an image-to-video task.
52
+
53
+ Args:
54
+ id: The task id.
55
+
56
+ Returns:
57
+ The current task status.
58
+ """
59
+ return self._request("get", f"{self.ENDPOINT}/{id}")
60
+
61
+ def _validate_params(self, params: Dict[str, Any]) -> None:
62
+ model = params.get("model")
63
+ if not model:
64
+ raise ValidationError("model is required")
65
+ if model not in IMAGE_TO_VIDEO_MODELS:
66
+ raise ValidationError(
67
+ f"Invalid model: {model}. Must be one of: {', '.join(IMAGE_TO_VIDEO_MODELS)}"
68
+ )
69
+
70
+ if not params.get("prompt"):
71
+ raise ValidationError("prompt is required")
72
+ if not params.get("first_frame_image_url"):
73
+ raise ValidationError("first_frame_image_url is required")
74
+
75
+ duration_seconds = params.get("duration_seconds")
76
+ if duration_seconds is not None and duration_seconds not in FIXED_DURATIONS:
77
+ raise ValidationError(
78
+ f"Invalid duration_seconds: {duration_seconds}. "
79
+ f"Must be one of: {', '.join(str(d) for d in FIXED_DURATIONS)}"
80
+ )
81
+
82
+ last_frame_image_url = params.get("last_frame_image_url")
83
+ if last_frame_image_url and model not in (
84
+ "kling-v2.5-turbo-image-to-video-pro",
85
+ "kling-v2.1-pro",
86
+ ):
87
+ raise ValidationError(
88
+ "last_frame_image_url is only supported by "
89
+ "kling-v2.5-turbo-image-to-video-pro and kling-v2.1-pro"
90
+ )
@@ -0,0 +1,80 @@
1
+ """Kling motion control resource."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict
6
+
7
+ from runapi.core import Resource, ValidationError
8
+
9
+ from ..types import (
10
+ MOTION_CONTROL_BACKGROUND_SOURCES,
11
+ MOTION_CONTROL_CHARACTER_ORIENTATIONS,
12
+ MOTION_CONTROL_MODELS,
13
+ MOTION_CONTROL_OUTPUT_RESOLUTIONS,
14
+ CompletedMotionControlResponse,
15
+ MotionControlResponse,
16
+ )
17
+
18
+
19
+ class MotionControl(Resource):
20
+ """Generate videos with motion transfer from reference videos."""
21
+
22
+ ENDPOINT = "/api/v1/kling/motion_control"
23
+
24
+ RESPONSE_CLASS = MotionControlResponse
25
+ COMPLETED_RESPONSE_CLASS = CompletedMotionControlResponse
26
+
27
+ def run(self, **params: Any) -> Any:
28
+ """Generate a motion control video and poll until it completes.
29
+
30
+ Args:
31
+ **params: Motion control parameters (model, source_image_url, reference_video_url, ...).
32
+
33
+ Returns:
34
+ The completed task with videos.
35
+ """
36
+ task = self.create(**params)
37
+ return self._poll_until_complete(lambda: self.get(task.id))
38
+
39
+ def create(self, **params: Any) -> Any:
40
+ """Create a motion control task and return immediately with an ``id``.
41
+
42
+ Args:
43
+ **params: Motion control parameters (model, source_image_url, reference_video_url, ...).
44
+
45
+ Returns:
46
+ The task creation result with an id.
47
+ """
48
+ compacted = self._compact_params(params)
49
+ self._validate_params(compacted)
50
+ return self._request("post", self.ENDPOINT, body=compacted)
51
+
52
+ def get(self, id: str) -> Any:
53
+ """Fetch the current status of a motion control task.
54
+
55
+ Args:
56
+ id: The task id.
57
+
58
+ Returns:
59
+ The current task status.
60
+ """
61
+ return self._request("get", f"{self.ENDPOINT}/{id}")
62
+
63
+ def _validate_params(self, params: Dict[str, Any]) -> None:
64
+ model = params.get("model")
65
+ if not model:
66
+ raise ValidationError("model is required")
67
+ if model not in MOTION_CONTROL_MODELS:
68
+ raise ValidationError(
69
+ f"Invalid model: {model}. Must be one of: {', '.join(MOTION_CONTROL_MODELS)}"
70
+ )
71
+
72
+ self._validate_optional(params, "output_resolution", MOTION_CONTROL_OUTPUT_RESOLUTIONS)
73
+ self._validate_optional(params, "character_orientation", MOTION_CONTROL_CHARACTER_ORIENTATIONS)
74
+ self._validate_optional(params, "background_source", MOTION_CONTROL_BACKGROUND_SOURCES)
75
+
76
+ if not params.get("source_image_url"):
77
+ raise ValidationError("source_image_url is required")
78
+
79
+ if not params.get("reference_video_url"):
80
+ raise ValidationError("reference_video_url is required")
@@ -0,0 +1,136 @@
1
+ """Kling text-to-video resource."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict
6
+
7
+ from runapi.core import Resource, ValidationError
8
+
9
+ from ..types import (
10
+ ASPECT_RATIOS,
11
+ DURATION_RANGE,
12
+ FIXED_DURATIONS,
13
+ MULTI_PROMPT_DURATION_RANGE,
14
+ MULTI_PROMPT_MAX_LENGTH,
15
+ TEXT_TO_VIDEO_MODELS,
16
+ TEXT_TO_VIDEO_OUTPUT_RESOLUTIONS,
17
+ CompletedTextToVideoResponse,
18
+ TextToVideoResponse,
19
+ )
20
+
21
+
22
+ class TextToVideo(Resource):
23
+ """Generate videos from text prompts with Kling models."""
24
+
25
+ ENDPOINT = "/api/v1/kling/text_to_video"
26
+
27
+ RESPONSE_CLASS = TextToVideoResponse
28
+ COMPLETED_RESPONSE_CLASS = CompletedTextToVideoResponse
29
+
30
+ def run(self, **params: Any) -> Any:
31
+ """Generate a video from text and poll until it completes.
32
+
33
+ Args:
34
+ **params: Text-to-video parameters (model, prompt, ...).
35
+
36
+ Returns:
37
+ The completed task with videos.
38
+ """
39
+ task = self.create(**params)
40
+ return self._poll_until_complete(lambda: self.get(task.id))
41
+
42
+ def create(self, **params: Any) -> Any:
43
+ """Create a text-to-video task and return immediately with an ``id``.
44
+
45
+ Args:
46
+ **params: Text-to-video parameters (model, prompt, ...).
47
+
48
+ Returns:
49
+ The task creation result with an id.
50
+ """
51
+ compacted = self._compact_params(params)
52
+ self._validate_params(compacted)
53
+ return self._request("post", self.ENDPOINT, body=compacted)
54
+
55
+ def get(self, id: str) -> Any:
56
+ """Fetch the current status of a text-to-video task.
57
+
58
+ Args:
59
+ id: The task id.
60
+
61
+ Returns:
62
+ The current task status.
63
+ """
64
+ return self._request("get", f"{self.ENDPOINT}/{id}")
65
+
66
+ def _validate_params(self, params: Dict[str, Any]) -> None:
67
+ model = params.get("model")
68
+ if not model:
69
+ raise ValidationError("model is required")
70
+ if model not in TEXT_TO_VIDEO_MODELS:
71
+ raise ValidationError(
72
+ f"Invalid model: {model}. Must be one of: {', '.join(TEXT_TO_VIDEO_MODELS)}"
73
+ )
74
+
75
+ multi_shots = params.get("multi_shots") is True
76
+
77
+ if multi_shots:
78
+ if params.get("enable_sound") is not True:
79
+ raise ValidationError("enable_sound must be true when multi_shots is true")
80
+ if params.get("last_frame_image_url"):
81
+ raise ValidationError("last_frame_image_url is not supported when multi_shots is true")
82
+
83
+ self._validate_multi_prompt(params.get("multi_prompt"))
84
+ else:
85
+ if not params.get("prompt"):
86
+ raise ValidationError("prompt is required")
87
+
88
+ self._validate_optional(params, "output_resolution", TEXT_TO_VIDEO_OUTPUT_RESOLUTIONS)
89
+ self._validate_optional(params, "aspect_ratio", ASPECT_RATIOS)
90
+
91
+ duration_seconds = params.get("duration_seconds")
92
+ if duration_seconds is not None:
93
+ try:
94
+ dur_int = int(duration_seconds)
95
+ except (TypeError, ValueError):
96
+ dur_int = None
97
+ if model in ("kling-v2.1-master-text-to-video", "kling-v2.5-turbo-text-to-video-pro"):
98
+ if duration_seconds not in FIXED_DURATIONS:
99
+ raise ValidationError(
100
+ f"Invalid duration_seconds: {duration_seconds}. "
101
+ f"Must be one of: {', '.join(str(d) for d in FIXED_DURATIONS)}"
102
+ )
103
+ elif dur_int is None or dur_int not in DURATION_RANGE:
104
+ raise ValidationError(
105
+ f"Invalid duration_seconds: {duration_seconds}. "
106
+ f"Must be an integer between {DURATION_RANGE.start} and {DURATION_RANGE.stop - 1}"
107
+ )
108
+
109
+ def _validate_multi_prompt(self, multi_prompt: Any) -> None:
110
+ if not (isinstance(multi_prompt, list) and len(multi_prompt) > 0):
111
+ raise ValidationError("multi_prompt must be a non-empty array when multi_shots is true")
112
+
113
+ for index, shot in enumerate(multi_prompt):
114
+ prompt = shot.get("prompt") if isinstance(shot, dict) else None
115
+ duration_seconds = shot.get("duration_seconds") if isinstance(shot, dict) else None
116
+
117
+ if prompt is None or len(prompt) == 0:
118
+ raise ValidationError(f"multi_prompt[{index}].prompt is required")
119
+
120
+ if len(prompt) > MULTI_PROMPT_MAX_LENGTH:
121
+ raise ValidationError(
122
+ f"multi_prompt[{index}].prompt exceeds {MULTI_PROMPT_MAX_LENGTH} characters"
123
+ )
124
+
125
+ if duration_seconds is None:
126
+ raise ValidationError(f"multi_prompt[{index}].duration_seconds is required")
127
+
128
+ try:
129
+ dur_int = int(duration_seconds)
130
+ except (TypeError, ValueError):
131
+ dur_int = None
132
+ if dur_int is None or dur_int not in MULTI_PROMPT_DURATION_RANGE:
133
+ raise ValidationError(
134
+ f"multi_prompt[{index}].duration_seconds must be between "
135
+ f"{MULTI_PROMPT_DURATION_RANGE.start} and {MULTI_PROMPT_DURATION_RANGE.stop - 1}"
136
+ )
@@ -0,0 +1,110 @@
1
+ """Kling model lists, enums, and response models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from runapi.core import BaseModel, TaskResponse, optional, required
6
+
7
+ TEXT_TO_VIDEO_MODELS = [
8
+ "kling-3.0",
9
+ "kling-v2.5-turbo-text-to-video-pro",
10
+ "kling-v2.1-master-text-to-video",
11
+ ]
12
+
13
+ AI_AVATAR_MODELS = [
14
+ "kling-ai-avatar-pro",
15
+ "kling-ai-avatar-standard",
16
+ "kling-ai-avatar-v1-pro",
17
+ "kling-v1-avatar-standard",
18
+ ]
19
+
20
+ IMAGE_TO_VIDEO_MODELS = [
21
+ "kling-v2.5-turbo-image-to-video-pro",
22
+ "kling-v2.1-pro",
23
+ "kling-v2.1-standard",
24
+ "kling-v2.1-master-image-to-video",
25
+ ]
26
+
27
+ TEXT_TO_VIDEO_OUTPUT_RESOLUTIONS = ["720p", "1080p", "4k"]
28
+
29
+ MOTION_CONTROL_MODELS = ["kling-3.0"]
30
+
31
+ MOTION_CONTROL_OUTPUT_RESOLUTIONS = ["720p", "1080p"]
32
+
33
+ MOTION_CONTROL_CHARACTER_ORIENTATIONS = ["video", "image"]
34
+
35
+ MOTION_CONTROL_BACKGROUND_SOURCES = ["video", "image"]
36
+
37
+ ASPECT_RATIOS = ["16:9", "9:16", "1:1"]
38
+
39
+ DURATION_RANGE = range(3, 16)
40
+
41
+ MULTI_PROMPT_DURATION_RANGE = range(1, 13)
42
+
43
+ FIXED_DURATIONS = [5, 10]
44
+
45
+ MULTI_PROMPT_MAX_LENGTH = 500
46
+
47
+
48
+ class Video(BaseModel):
49
+ url = optional(str)
50
+
51
+
52
+ class AsyncTaskResponse(TaskResponse):
53
+ """Base response for an asynchronous Kling task."""
54
+
55
+ id = required(str)
56
+ status = optional(str, enum=lambda: TaskResponse.Status.ALL)
57
+
58
+
59
+ class TextToVideoResponse(AsyncTaskResponse):
60
+ """Response for a text-to-video task."""
61
+
62
+ videos = optional([lambda: Video])
63
+ error = optional(str)
64
+
65
+
66
+ class AiAvatarResponse(AsyncTaskResponse):
67
+ """Response for an AI avatar task."""
68
+
69
+ videos = optional([lambda: Video])
70
+ error = optional(str)
71
+
72
+
73
+ class ImageToVideoResponse(AsyncTaskResponse):
74
+ """Response for an image-to-video task."""
75
+
76
+ videos = optional([lambda: Video])
77
+ error = optional(str)
78
+
79
+
80
+ class MotionControlResponse(AsyncTaskResponse):
81
+ """Response for a motion control task."""
82
+
83
+ videos = optional([lambda: Video])
84
+ error = optional(str)
85
+
86
+
87
+ # Narrowed responses returned by ``run()`` once polling observes completion.
88
+ # ``videos`` is required so consumers never have to null-check it on success.
89
+ class CompletedTextToVideoResponse(TextToVideoResponse):
90
+ """Narrowed text-to-video response once polling observes completion."""
91
+
92
+ videos = required([lambda: Video])
93
+
94
+
95
+ class CompletedAiAvatarResponse(AiAvatarResponse):
96
+ """Narrowed AI avatar response once polling observes completion."""
97
+
98
+ videos = required([lambda: Video])
99
+
100
+
101
+ class CompletedImageToVideoResponse(ImageToVideoResponse):
102
+ """Narrowed image-to-video response once polling observes completion."""
103
+
104
+ videos = required([lambda: Video])
105
+
106
+
107
+ class CompletedMotionControlResponse(MotionControlResponse):
108
+ """Narrowed motion control response once polling observes completion."""
109
+
110
+ videos = required([lambda: Video])
@@ -0,0 +1,519 @@
1
+ import pytest
2
+
3
+ from runapi.core import config
4
+ from runapi.core.errors import AuthenticationError, ValidationError
5
+ from runapi.kling import KlingClient
6
+ from runapi.kling.resources.ai_avatar import AiAvatar
7
+ from runapi.kling.resources.image_to_video import ImageToVideo
8
+ from runapi.kling.resources.motion_control import MotionControl
9
+ from runapi.kling.resources.text_to_video import TextToVideo
10
+ from runapi.kling.types import (
11
+ AiAvatarResponse,
12
+ CompletedAiAvatarResponse,
13
+ CompletedImageToVideoResponse,
14
+ CompletedMotionControlResponse,
15
+ CompletedTextToVideoResponse,
16
+ ImageToVideoResponse,
17
+ MotionControlResponse,
18
+ TextToVideoResponse,
19
+ )
20
+
21
+
22
+ class FakeHttp:
23
+ def __init__(self, *responses):
24
+ self._responses = list(responses)
25
+ self.calls = []
26
+
27
+ def request(self, method, path, body=None, options=None):
28
+ self.calls.append((method, path, body))
29
+ if self._responses:
30
+ return self._responses.pop(0)
31
+ return {"id": "task_1", "status": "pending"}
32
+
33
+
34
+ @pytest.fixture(autouse=True)
35
+ def reset_config(monkeypatch):
36
+ monkeypatch.delenv("RUNAPI_API_KEY", raising=False)
37
+ monkeypatch.setattr(config, "api_key", None)
38
+ yield
39
+
40
+
41
+ # --- auth -----------------------------------------------------------------
42
+
43
+
44
+ def test_accepts_api_key_parameter():
45
+ assert isinstance(KlingClient(api_key="k", http_client=FakeHttp()), KlingClient)
46
+
47
+
48
+ def test_falls_back_to_global(monkeypatch):
49
+ monkeypatch.setattr(config, "api_key", "global-key")
50
+ assert isinstance(KlingClient(http_client=FakeHttp()), KlingClient)
51
+
52
+
53
+ def test_falls_back_to_env(monkeypatch):
54
+ monkeypatch.setenv("RUNAPI_API_KEY", "env-key")
55
+ assert isinstance(KlingClient(http_client=FakeHttp()), KlingClient)
56
+
57
+
58
+ def test_raises_without_api_key():
59
+ with pytest.raises(AuthenticationError, match="API key is required"):
60
+ KlingClient()
61
+
62
+
63
+ # --- injection / accessors ------------------------------------------------
64
+
65
+
66
+ def test_uses_injected_http_client():
67
+ fake = FakeHttp()
68
+ client = KlingClient(api_key="k", http_client=fake)
69
+ assert client.text_to_video._http is fake
70
+ assert client.ai_avatar._http is fake
71
+ assert client.image_to_video._http is fake
72
+ assert client.motion_control._http is fake
73
+
74
+
75
+ def test_exposes_resource_accessors():
76
+ client = KlingClient(api_key="k", http_client=FakeHttp())
77
+ assert isinstance(client.text_to_video, TextToVideo)
78
+ assert isinstance(client.ai_avatar, AiAvatar)
79
+ assert isinstance(client.image_to_video, ImageToVideo)
80
+ assert isinstance(client.motion_control, MotionControl)
81
+
82
+
83
+ # --- text_to_video --------------------------------------------------------
84
+
85
+
86
+ def test_text_to_video_create_posts_compacted_body():
87
+ fake = FakeHttp({"id": "t1", "status": "pending"})
88
+ client = KlingClient(api_key="k", http_client=fake)
89
+ result = client.text_to_video.create(
90
+ model="kling-3.0", prompt="a cat in a garden", aspect_ratio="16:9", seed=None
91
+ )
92
+ assert fake.calls == [
93
+ (
94
+ "post",
95
+ "/api/v1/kling/text_to_video",
96
+ {"model": "kling-3.0", "prompt": "a cat in a garden", "aspect_ratio": "16:9"},
97
+ ),
98
+ ]
99
+ assert isinstance(result, TextToVideoResponse)
100
+
101
+
102
+ def test_text_to_video_get_fetches_by_id():
103
+ fake = FakeHttp({"id": "t1", "status": "processing"})
104
+ client = KlingClient(api_key="k", http_client=fake)
105
+ client.text_to_video.get("t1")
106
+ assert fake.calls == [("get", "/api/v1/kling/text_to_video/t1", None)]
107
+
108
+
109
+ def test_text_to_video_run_narrows_completed_type():
110
+ fake = FakeHttp(
111
+ {"id": "t1", "status": "pending"},
112
+ {"id": "t1", "status": "completed", "videos": [{"url": "https://x/y.mp4"}]},
113
+ )
114
+ client = KlingClient(api_key="k", http_client=fake)
115
+ result = client.text_to_video.run(model="kling-3.0", prompt="a serene forest")
116
+ assert isinstance(result, CompletedTextToVideoResponse)
117
+ assert result.videos[0].url == "https://x/y.mp4"
118
+
119
+
120
+ def test_text_to_video_rejects_missing_model():
121
+ client = KlingClient(api_key="k", http_client=FakeHttp())
122
+ with pytest.raises(ValidationError, match="model is required"):
123
+ client.text_to_video.create(prompt="hello")
124
+
125
+
126
+ def test_text_to_video_rejects_unknown_model():
127
+ client = KlingClient(api_key="k", http_client=FakeHttp())
128
+ with pytest.raises(ValidationError, match="Invalid model"):
129
+ client.text_to_video.create(model="nope", prompt="hello")
130
+
131
+
132
+ def test_text_to_video_requires_prompt():
133
+ client = KlingClient(api_key="k", http_client=FakeHttp())
134
+ with pytest.raises(ValidationError, match="prompt is required"):
135
+ client.text_to_video.create(model="kling-3.0")
136
+
137
+
138
+ def test_text_to_video_multi_shots_requires_enable_sound():
139
+ client = KlingClient(api_key="k", http_client=FakeHttp())
140
+ with pytest.raises(ValidationError, match="enable_sound must be true when multi_shots is true"):
141
+ client.text_to_video.create(model="kling-3.0", multi_shots=True)
142
+
143
+
144
+ def test_text_to_video_multi_shots_rejects_last_frame():
145
+ client = KlingClient(api_key="k", http_client=FakeHttp())
146
+ with pytest.raises(
147
+ ValidationError, match="last_frame_image_url is not supported when multi_shots is true"
148
+ ):
149
+ client.text_to_video.create(
150
+ model="kling-3.0",
151
+ multi_shots=True,
152
+ enable_sound=True,
153
+ last_frame_image_url="https://x/a.png",
154
+ multi_prompt=[{"prompt": "shot one", "duration_seconds": 5}],
155
+ )
156
+
157
+
158
+ def test_text_to_video_multi_prompt_must_be_non_empty():
159
+ client = KlingClient(api_key="k", http_client=FakeHttp())
160
+ with pytest.raises(
161
+ ValidationError, match="multi_prompt must be a non-empty array when multi_shots is true"
162
+ ):
163
+ client.text_to_video.create(
164
+ model="kling-3.0", multi_shots=True, enable_sound=True, multi_prompt=[]
165
+ )
166
+
167
+
168
+ def test_text_to_video_multi_prompt_requires_prompt():
169
+ client = KlingClient(api_key="k", http_client=FakeHttp())
170
+ with pytest.raises(ValidationError, match=r"multi_prompt\[0\].prompt is required"):
171
+ client.text_to_video.create(
172
+ model="kling-3.0",
173
+ multi_shots=True,
174
+ enable_sound=True,
175
+ multi_prompt=[{"duration_seconds": 5}],
176
+ )
177
+
178
+
179
+ def test_text_to_video_multi_prompt_max_length():
180
+ client = KlingClient(api_key="k", http_client=FakeHttp())
181
+ with pytest.raises(ValidationError, match=r"multi_prompt\[0\].prompt exceeds 500 characters"):
182
+ client.text_to_video.create(
183
+ model="kling-3.0",
184
+ multi_shots=True,
185
+ enable_sound=True,
186
+ multi_prompt=[{"prompt": "x" * 501, "duration_seconds": 5}],
187
+ )
188
+
189
+
190
+ def test_text_to_video_multi_prompt_requires_duration():
191
+ client = KlingClient(api_key="k", http_client=FakeHttp())
192
+ with pytest.raises(ValidationError, match=r"multi_prompt\[0\].duration_seconds is required"):
193
+ client.text_to_video.create(
194
+ model="kling-3.0",
195
+ multi_shots=True,
196
+ enable_sound=True,
197
+ multi_prompt=[{"prompt": "shot one"}],
198
+ )
199
+
200
+
201
+ def test_text_to_video_multi_prompt_duration_range():
202
+ client = KlingClient(api_key="k", http_client=FakeHttp())
203
+ with pytest.raises(
204
+ ValidationError, match=r"multi_prompt\[0\].duration_seconds must be between 1 and 12"
205
+ ):
206
+ client.text_to_video.create(
207
+ model="kling-3.0",
208
+ multi_shots=True,
209
+ enable_sound=True,
210
+ multi_prompt=[{"prompt": "shot one", "duration_seconds": 20}],
211
+ )
212
+
213
+
214
+ def test_text_to_video_rejects_invalid_output_resolution():
215
+ client = KlingClient(api_key="k", http_client=FakeHttp())
216
+ with pytest.raises(ValidationError, match="Invalid output_resolution"):
217
+ client.text_to_video.create(model="kling-3.0", prompt="hello", output_resolution="9k")
218
+
219
+
220
+ def test_text_to_video_rejects_invalid_aspect_ratio():
221
+ client = KlingClient(api_key="k", http_client=FakeHttp())
222
+ with pytest.raises(ValidationError, match="Invalid aspect_ratio"):
223
+ client.text_to_video.create(model="kling-3.0", prompt="hello", aspect_ratio="4:3")
224
+
225
+
226
+ def test_text_to_video_fixed_duration_models():
227
+ client = KlingClient(api_key="k", http_client=FakeHttp())
228
+ with pytest.raises(ValidationError, match="Must be one of: 5, 10"):
229
+ client.text_to_video.create(
230
+ model="kling-v2.1-master-text-to-video", prompt="hello", duration_seconds=7
231
+ )
232
+
233
+
234
+ def test_text_to_video_range_duration_models():
235
+ client = KlingClient(api_key="k", http_client=FakeHttp())
236
+ with pytest.raises(
237
+ ValidationError, match="Must be an integer between 3 and 15"
238
+ ):
239
+ client.text_to_video.create(model="kling-3.0", prompt="hello", duration_seconds=20)
240
+
241
+
242
+ # --- ai_avatar ------------------------------------------------------------
243
+
244
+
245
+ def test_ai_avatar_create_posts_body():
246
+ fake = FakeHttp({"id": "t1", "status": "pending"})
247
+ client = KlingClient(api_key="k", http_client=fake)
248
+ result = client.ai_avatar.create(
249
+ model="kling-ai-avatar-pro",
250
+ prompt="a host greeting",
251
+ source_image_url="https://x/p.jpg",
252
+ source_audio_url="https://x/a.mp3",
253
+ )
254
+ assert fake.calls == [
255
+ (
256
+ "post",
257
+ "/api/v1/kling/ai_avatar",
258
+ {
259
+ "model": "kling-ai-avatar-pro",
260
+ "prompt": "a host greeting",
261
+ "source_image_url": "https://x/p.jpg",
262
+ "source_audio_url": "https://x/a.mp3",
263
+ },
264
+ ),
265
+ ]
266
+ assert isinstance(result, AiAvatarResponse)
267
+
268
+
269
+ def test_ai_avatar_get_fetches_by_id():
270
+ fake = FakeHttp({"id": "t1", "status": "processing"})
271
+ client = KlingClient(api_key="k", http_client=fake)
272
+ client.ai_avatar.get("t1")
273
+ assert fake.calls == [("get", "/api/v1/kling/ai_avatar/t1", None)]
274
+
275
+
276
+ def test_ai_avatar_run_narrows_completed_type():
277
+ fake = FakeHttp(
278
+ {"id": "t1", "status": "pending"},
279
+ {"id": "t1", "status": "completed", "videos": [{"url": "https://x/a.mp4"}]},
280
+ )
281
+ client = KlingClient(api_key="k", http_client=fake)
282
+ result = client.ai_avatar.run(
283
+ model="kling-ai-avatar-pro",
284
+ prompt="a host",
285
+ source_image_url="https://x/p.jpg",
286
+ source_audio_url="https://x/a.mp3",
287
+ )
288
+ assert isinstance(result, CompletedAiAvatarResponse)
289
+ assert result.videos[0].url == "https://x/a.mp4"
290
+
291
+
292
+ def test_ai_avatar_rejects_unknown_model():
293
+ client = KlingClient(api_key="k", http_client=FakeHttp())
294
+ with pytest.raises(ValidationError, match="Invalid model"):
295
+ client.ai_avatar.create(model="nope")
296
+
297
+
298
+ def test_ai_avatar_requires_source_image_url():
299
+ client = KlingClient(api_key="k", http_client=FakeHttp())
300
+ with pytest.raises(ValidationError, match="source_image_url is required"):
301
+ client.ai_avatar.create(model="kling-ai-avatar-pro")
302
+
303
+
304
+ def test_ai_avatar_requires_source_audio_url():
305
+ client = KlingClient(api_key="k", http_client=FakeHttp())
306
+ with pytest.raises(ValidationError, match="source_audio_url is required"):
307
+ client.ai_avatar.create(model="kling-ai-avatar-pro", source_image_url="https://x/p.jpg")
308
+
309
+
310
+ def test_ai_avatar_requires_prompt():
311
+ client = KlingClient(api_key="k", http_client=FakeHttp())
312
+ with pytest.raises(ValidationError, match="prompt is required"):
313
+ client.ai_avatar.create(
314
+ model="kling-ai-avatar-pro",
315
+ source_image_url="https://x/p.jpg",
316
+ source_audio_url="https://x/a.mp3",
317
+ )
318
+
319
+
320
+ # --- image_to_video -------------------------------------------------------
321
+
322
+
323
+ def test_image_to_video_create_posts_body():
324
+ fake = FakeHttp({"id": "t1", "status": "pending"})
325
+ client = KlingClient(api_key="k", http_client=fake)
326
+ result = client.image_to_video.create(
327
+ model="kling-v2.1-pro",
328
+ prompt="zoom out slowly",
329
+ first_frame_image_url="https://x/f.jpg",
330
+ )
331
+ assert fake.calls == [
332
+ (
333
+ "post",
334
+ "/api/v1/kling/image_to_video",
335
+ {
336
+ "model": "kling-v2.1-pro",
337
+ "prompt": "zoom out slowly",
338
+ "first_frame_image_url": "https://x/f.jpg",
339
+ },
340
+ ),
341
+ ]
342
+ assert isinstance(result, ImageToVideoResponse)
343
+
344
+
345
+ def test_image_to_video_get_fetches_by_id():
346
+ fake = FakeHttp({"id": "t1", "status": "processing"})
347
+ client = KlingClient(api_key="k", http_client=fake)
348
+ client.image_to_video.get("t1")
349
+ assert fake.calls == [("get", "/api/v1/kling/image_to_video/t1", None)]
350
+
351
+
352
+ def test_image_to_video_run_narrows_completed_type():
353
+ fake = FakeHttp(
354
+ {"id": "t1", "status": "pending"},
355
+ {"id": "t1", "status": "completed", "videos": [{"url": "https://x/i.mp4"}]},
356
+ )
357
+ client = KlingClient(api_key="k", http_client=fake)
358
+ result = client.image_to_video.run(
359
+ model="kling-v2.1-pro", prompt="zoom", first_frame_image_url="https://x/f.jpg"
360
+ )
361
+ assert isinstance(result, CompletedImageToVideoResponse)
362
+ assert result.videos[0].url == "https://x/i.mp4"
363
+
364
+
365
+ def test_image_to_video_rejects_unknown_model():
366
+ client = KlingClient(api_key="k", http_client=FakeHttp())
367
+ with pytest.raises(ValidationError, match="Invalid model"):
368
+ client.image_to_video.create(model="nope")
369
+
370
+
371
+ def test_image_to_video_requires_prompt():
372
+ client = KlingClient(api_key="k", http_client=FakeHttp())
373
+ with pytest.raises(ValidationError, match="prompt is required"):
374
+ client.image_to_video.create(model="kling-v2.1-pro")
375
+
376
+
377
+ def test_image_to_video_requires_first_frame_image_url():
378
+ client = KlingClient(api_key="k", http_client=FakeHttp())
379
+ with pytest.raises(ValidationError, match="first_frame_image_url is required"):
380
+ client.image_to_video.create(model="kling-v2.1-pro", prompt="zoom")
381
+
382
+
383
+ def test_image_to_video_rejects_invalid_duration():
384
+ client = KlingClient(api_key="k", http_client=FakeHttp())
385
+ with pytest.raises(ValidationError, match="Must be one of: 5, 10"):
386
+ client.image_to_video.create(
387
+ model="kling-v2.1-pro",
388
+ prompt="zoom",
389
+ first_frame_image_url="https://x/f.jpg",
390
+ duration_seconds=7,
391
+ )
392
+
393
+
394
+ def test_image_to_video_last_frame_model_gate():
395
+ client = KlingClient(api_key="k", http_client=FakeHttp())
396
+ with pytest.raises(
397
+ ValidationError,
398
+ match="last_frame_image_url is only supported by kling-v2.5-turbo-image-to-video-pro and kling-v2.1-pro",
399
+ ):
400
+ client.image_to_video.create(
401
+ model="kling-v2.1-standard",
402
+ prompt="zoom",
403
+ first_frame_image_url="https://x/f.jpg",
404
+ last_frame_image_url="https://x/l.jpg",
405
+ )
406
+
407
+
408
+ def test_image_to_video_last_frame_allowed_for_supported_model():
409
+ fake = FakeHttp({"id": "t1", "status": "pending"})
410
+ client = KlingClient(api_key="k", http_client=fake)
411
+ client.image_to_video.create(
412
+ model="kling-v2.1-pro",
413
+ prompt="zoom",
414
+ first_frame_image_url="https://x/f.jpg",
415
+ last_frame_image_url="https://x/l.jpg",
416
+ )
417
+ assert fake.calls[0][2]["last_frame_image_url"] == "https://x/l.jpg"
418
+
419
+
420
+ # --- motion_control -------------------------------------------------------
421
+
422
+
423
+ def test_motion_control_create_posts_body():
424
+ fake = FakeHttp({"id": "t1", "status": "pending"})
425
+ client = KlingClient(api_key="k", http_client=fake)
426
+ result = client.motion_control.create(
427
+ model="kling-3.0",
428
+ source_image_url="https://x/s.jpg",
429
+ reference_video_url="https://x/r.mp4",
430
+ )
431
+ assert fake.calls == [
432
+ (
433
+ "post",
434
+ "/api/v1/kling/motion_control",
435
+ {
436
+ "model": "kling-3.0",
437
+ "source_image_url": "https://x/s.jpg",
438
+ "reference_video_url": "https://x/r.mp4",
439
+ },
440
+ ),
441
+ ]
442
+ assert isinstance(result, MotionControlResponse)
443
+
444
+
445
+ def test_motion_control_get_fetches_by_id():
446
+ fake = FakeHttp({"id": "t1", "status": "processing"})
447
+ client = KlingClient(api_key="k", http_client=fake)
448
+ client.motion_control.get("t1")
449
+ assert fake.calls == [("get", "/api/v1/kling/motion_control/t1", None)]
450
+
451
+
452
+ def test_motion_control_run_narrows_completed_type():
453
+ fake = FakeHttp(
454
+ {"id": "t1", "status": "pending"},
455
+ {"id": "t1", "status": "completed", "videos": [{"url": "https://x/m.mp4"}]},
456
+ )
457
+ client = KlingClient(api_key="k", http_client=fake)
458
+ result = client.motion_control.run(
459
+ model="kling-3.0",
460
+ source_image_url="https://x/s.jpg",
461
+ reference_video_url="https://x/r.mp4",
462
+ )
463
+ assert isinstance(result, CompletedMotionControlResponse)
464
+ assert result.videos[0].url == "https://x/m.mp4"
465
+
466
+
467
+ def test_motion_control_rejects_unknown_model():
468
+ client = KlingClient(api_key="k", http_client=FakeHttp())
469
+ with pytest.raises(ValidationError, match="Invalid model"):
470
+ client.motion_control.create(model="nope")
471
+
472
+
473
+ def test_motion_control_rejects_invalid_output_resolution():
474
+ client = KlingClient(api_key="k", http_client=FakeHttp())
475
+ with pytest.raises(ValidationError, match="Invalid output_resolution"):
476
+ client.motion_control.create(model="kling-3.0", output_resolution="4k")
477
+
478
+
479
+ def test_motion_control_rejects_invalid_character_orientation():
480
+ client = KlingClient(api_key="k", http_client=FakeHttp())
481
+ with pytest.raises(ValidationError, match="Invalid character_orientation"):
482
+ client.motion_control.create(model="kling-3.0", character_orientation="upside-down")
483
+
484
+
485
+ def test_motion_control_rejects_invalid_background_source():
486
+ client = KlingClient(api_key="k", http_client=FakeHttp())
487
+ with pytest.raises(ValidationError, match="Invalid background_source"):
488
+ client.motion_control.create(model="kling-3.0", background_source="audio")
489
+
490
+
491
+ def test_motion_control_requires_source_image_url():
492
+ client = KlingClient(api_key="k", http_client=FakeHttp())
493
+ with pytest.raises(ValidationError, match="source_image_url is required"):
494
+ client.motion_control.create(model="kling-3.0")
495
+
496
+
497
+ def test_motion_control_requires_reference_video_url():
498
+ client = KlingClient(api_key="k", http_client=FakeHttp())
499
+ with pytest.raises(ValidationError, match="reference_video_url is required"):
500
+ client.motion_control.create(model="kling-3.0", source_image_url="https://x/s.jpg")
501
+
502
+
503
+ def test_text_to_video_non_numeric_duration_raises_validation_error():
504
+ # Regression: a non-numeric duration must raise the SDK's ValidationError,
505
+ # not a bare ValueError from int(). Fails if int() is unguarded again.
506
+ client = KlingClient(api_key="k", http_client=FakeHttp())
507
+ with pytest.raises(ValidationError, match="Must be an integer between"):
508
+ client.text_to_video.create(model="kling-3.0", prompt="a cat", duration_seconds="abc")
509
+
510
+
511
+ def test_multi_prompt_non_numeric_duration_raises_validation_error():
512
+ client = KlingClient(api_key="k", http_client=FakeHttp())
513
+ with pytest.raises(ValidationError, match=r"multi_prompt\[0\].duration_seconds must be between"):
514
+ client.text_to_video.create(
515
+ model="kling-3.0",
516
+ multi_shots=True,
517
+ enable_sound=True,
518
+ multi_prompt=[{"prompt": "shot one", "duration_seconds": "abc"}],
519
+ )