runapi-wan 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,75 @@
1
+ Metadata-Version: 2.4
2
+ Name: runapi-wan
3
+ Version: 0.1.0
4
+ Summary: Wan video and text-to-image client for RunAPI
5
+ Project-URL: Homepage, https://runapi.ai/models/wan
6
+ Project-URL: Documentation, https://runapi.ai/docs#sdk-wan
7
+ Author-email: RunAPI <contact@runapi.ai>
8
+ License-Expression: Apache-2.0
9
+ Keywords: ai,image-to-video,runapi,sdk,text-to-image,text-to-video,wan
10
+ Requires-Python: >=3.9
11
+ Requires-Dist: runapi-core
12
+ Description-Content-Type: text/markdown
13
+
14
+ # Wan Video API Python SDK for RunAPI
15
+
16
+ The wan video api Python SDK is the language-specific package for Wan on RunAPI. Use this wan video api package for text-to-video, image-to-video, animation, and video editing flows when your application needs JSON request bodies, task status lookup, and consistent RunAPI errors in Python.
17
+
18
+ This wan video api README is the Python package guide inside the public `wan-sdk` repository. For the repository overview, start at `../README.md`; for model details, use https://runapi.ai/models/wan; for API reference, use https://runapi.ai/docs#wan; for SDK docs, use https://runapi.ai/docs#sdk-wan.
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ pip install runapi-wan
24
+ ```
25
+
26
+ ## Quick start
27
+
28
+ ```python
29
+ from runapi.wan import WanClient
30
+
31
+ client = WanClient() # reads RUNAPI_API_KEY, or pass api_key="sk-..."
32
+
33
+ task = client.text_to_video.create(
34
+ model="wan-2.6-text-to-video",
35
+ prompt="A scenic mountain landscape with flowing rivers",
36
+ )
37
+ status = client.text_to_video.get(task.id)
38
+
39
+ image = client.text_to_image.create(
40
+ model="wan-2.7-image",
41
+ prompt="A neon city street after rain, cinematic",
42
+ )
43
+ ```
44
+
45
+ Use `create` to submit a task and return quickly, `get` to fetch the latest task state, and `run` when a script should create and poll until completion:
46
+
47
+ ```python
48
+ result = client.text_to_video.run(
49
+ model="wan-2.6-text-to-video",
50
+ prompt="A serene mountain lake at dawn",
51
+ )
52
+ print(result.videos[0].url)
53
+ ```
54
+
55
+ In web request handlers, prefer `create` plus webhook or later `get` polling so a worker is not held open.
56
+
57
+ RunAPI-generated file URLs are temporary. Download and store generated images, videos, audio, or other files in your own durable storage within 7 days; do not treat returned URLs as long-term assets.
58
+
59
+ ## Language notes
60
+
61
+ Pass parameters as keyword arguments and catch the `runapi.wan` error classes when building media jobs or scripts. The available resources are `text_to_video`, `image_to_video`, `speech_to_video`, `animate`, `text_to_image`, and `edit_video`. Keep `RUNAPI_API_KEY` in the environment or your secret manager; never commit API keys or callback secrets.
62
+
63
+ ## Links
64
+
65
+ - Model page: https://runapi.ai/models/wan
66
+ - SDK docs: https://runapi.ai/docs#sdk-wan
67
+ - Product docs: https://runapi.ai/docs#wan
68
+ - Pricing and rate limits: https://runapi.ai/models/wan/2.2-a14b-text-to-video-turbo
69
+ - Provider comparison: https://runapi.ai/providers/alibaba
70
+ - Full catalog: https://runapi.ai/models
71
+ - Repository: https://github.com/runapi-ai/wan-sdk
72
+
73
+ ## License
74
+
75
+ Licensed under the Apache License, Version 2.0.
@@ -0,0 +1,62 @@
1
+ # Wan Video API Python SDK for RunAPI
2
+
3
+ The wan video api Python SDK is the language-specific package for Wan on RunAPI. Use this wan video api package for text-to-video, image-to-video, animation, and video editing flows when your application needs JSON request bodies, task status lookup, and consistent RunAPI errors in Python.
4
+
5
+ This wan video api README is the Python package guide inside the public `wan-sdk` repository. For the repository overview, start at `../README.md`; for model details, use https://runapi.ai/models/wan; for API reference, use https://runapi.ai/docs#wan; for SDK docs, use https://runapi.ai/docs#sdk-wan.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install runapi-wan
11
+ ```
12
+
13
+ ## Quick start
14
+
15
+ ```python
16
+ from runapi.wan import WanClient
17
+
18
+ client = WanClient() # reads RUNAPI_API_KEY, or pass api_key="sk-..."
19
+
20
+ task = client.text_to_video.create(
21
+ model="wan-2.6-text-to-video",
22
+ prompt="A scenic mountain landscape with flowing rivers",
23
+ )
24
+ status = client.text_to_video.get(task.id)
25
+
26
+ image = client.text_to_image.create(
27
+ model="wan-2.7-image",
28
+ prompt="A neon city street after rain, cinematic",
29
+ )
30
+ ```
31
+
32
+ Use `create` to submit a task and return quickly, `get` to fetch the latest task state, and `run` when a script should create and poll until completion:
33
+
34
+ ```python
35
+ result = client.text_to_video.run(
36
+ model="wan-2.6-text-to-video",
37
+ prompt="A serene mountain lake at dawn",
38
+ )
39
+ print(result.videos[0].url)
40
+ ```
41
+
42
+ In web request handlers, prefer `create` plus webhook or later `get` polling so a worker is not held open.
43
+
44
+ RunAPI-generated file URLs are temporary. Download and store generated images, videos, audio, or other files in your own durable storage within 7 days; do not treat returned URLs as long-term assets.
45
+
46
+ ## Language notes
47
+
48
+ Pass parameters as keyword arguments and catch the `runapi.wan` error classes when building media jobs or scripts. The available resources are `text_to_video`, `image_to_video`, `speech_to_video`, `animate`, `text_to_image`, and `edit_video`. Keep `RUNAPI_API_KEY` in the environment or your secret manager; never commit API keys or callback secrets.
49
+
50
+ ## Links
51
+
52
+ - Model page: https://runapi.ai/models/wan
53
+ - SDK docs: https://runapi.ai/docs#sdk-wan
54
+ - Product docs: https://runapi.ai/docs#wan
55
+ - Pricing and rate limits: https://runapi.ai/models/wan/2.2-a14b-text-to-video-turbo
56
+ - Provider comparison: https://runapi.ai/providers/alibaba
57
+ - Full catalog: https://runapi.ai/models
58
+ - Repository: https://github.com/runapi-ai/wan-sdk
59
+
60
+ ## License
61
+
62
+ Licensed under the Apache License, Version 2.0.
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "runapi-wan"
7
+ version = "0.1.0"
8
+ description = "Wan video and text-to-image 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", "wan", "text-to-video", "image-to-video", "text-to-image", "ai", "sdk"]
14
+ dependencies = ["runapi-core"]
15
+
16
+ [project.urls]
17
+ Homepage = "https://runapi.ai/models/wan"
18
+ Documentation = "https://runapi.ai/docs#sdk-wan"
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 = { workspace = true }
28
+
29
+ [dependency-groups]
30
+ dev = ["pytest>=8"]
31
+
32
+ [tool.runapi]
33
+ slug = "wan"
@@ -0,0 +1,24 @@
1
+ """Wan 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 WanClient
14
+
15
+ __all__ = [
16
+ "WanClient",
17
+ "AuthenticationError",
18
+ "RateLimitError",
19
+ "InsufficientCreditsError",
20
+ "NotFoundError",
21
+ "ValidationError",
22
+ "TaskFailedError",
23
+ "TaskTimeoutError",
24
+ ]
@@ -0,0 +1,38 @@
1
+ """Wan 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.animate import Animate
10
+ from .resources.edit_video import EditVideo
11
+ from .resources.image_to_video import ImageToVideo
12
+ from .resources.speech_to_video import SpeechToVideo
13
+ from .resources.text_to_image import TextToImage
14
+ from .resources.text_to_video import TextToVideo
15
+
16
+
17
+ class WanClient:
18
+ """Wan video and text-to-image client.
19
+
20
+ Example::
21
+
22
+ client = WanClient(api_key="sk-...")
23
+ result = client.text_to_video.run(
24
+ model="wan-2.6-text-to-video",
25
+ prompt="A scenic mountain landscape with flowing rivers",
26
+ )
27
+ """
28
+
29
+ def __init__(self, api_key: Optional[str] = None, **options: Any) -> None:
30
+ resolved_api_key = resolve_api_key(api_key)
31
+ client_options = ClientOptions(api_key=resolved_api_key, **options)
32
+ http = client_options.http_client or HttpClient(client_options)
33
+ self.text_to_video = TextToVideo(http)
34
+ self.image_to_video = ImageToVideo(http)
35
+ self.speech_to_video = SpeechToVideo(http)
36
+ self.animate = Animate(http)
37
+ self.text_to_image = TextToImage(http)
38
+ self.edit_video = EditVideo(http)
@@ -0,0 +1,298 @@
1
+ # Code generated by SdkContractGenerator. DO NOT EDIT.
2
+
3
+ CONTRACT = {
4
+ "animate": {
5
+ "models": ["wan-2.2-animate-move", "wan-2.2-animate-replace"],
6
+ "fields_by_model": {
7
+ "wan-2.2-animate-move": {
8
+ "output_resolution": {
9
+ "enum": ["480p", "580p", "720p"]
10
+ },
11
+ "reference_video_url": {
12
+ "required": True
13
+ },
14
+ "source_image_url": {
15
+ "required": True
16
+ }
17
+ },
18
+ "wan-2.2-animate-replace": {
19
+ "output_resolution": {
20
+ "enum": ["480p", "580p", "720p"]
21
+ },
22
+ "reference_video_url": {
23
+ "required": True
24
+ },
25
+ "source_image_url": {
26
+ "required": True
27
+ }
28
+ }
29
+ }
30
+ },
31
+ "edit-video": {
32
+ "models": ["wan-2.6-edit-video", "wan-2.6-flash-edit-video", "wan-2.7-edit-video"],
33
+ "fields_by_model": {
34
+ "wan-2.6-edit-video": {
35
+ "duration_seconds": {
36
+ "type": "integer"
37
+ },
38
+ "output_resolution": {
39
+ "enum": ["720p", "1080p"]
40
+ },
41
+ "prompt": {
42
+ "required": True
43
+ },
44
+ "seed": {
45
+ "type": "integer"
46
+ },
47
+ "source_video_urls": {
48
+ "required": True
49
+ }
50
+ },
51
+ "wan-2.6-flash-edit-video": {
52
+ "duration_seconds": {
53
+ "type": "integer"
54
+ },
55
+ "prompt": {
56
+ "required": True
57
+ },
58
+ "seed": {
59
+ "type": "integer"
60
+ },
61
+ "source_video_urls": {
62
+ "required": True
63
+ }
64
+ },
65
+ "wan-2.7-edit-video": {
66
+ "aspect_ratio": {
67
+ "enum": ["16:9", "9:16", "1:1", "4:3", "3:4"]
68
+ },
69
+ "duration_seconds": {
70
+ "type": "integer"
71
+ },
72
+ "output_resolution": {
73
+ "enum": ["720p", "1080p"]
74
+ },
75
+ "seed": {
76
+ "type": "integer"
77
+ },
78
+ "source_video_url": {
79
+ "required": True
80
+ }
81
+ }
82
+ }
83
+ },
84
+ "image-to-video": {
85
+ "models": ["wan-2.2-a14b-image-to-video-turbo", "wan-2.5-image-to-video", "wan-2.6-flash-image-to-video", "wan-2.6-image-to-video", "wan-2.7-image-to-video"],
86
+ "fields_by_model": {
87
+ "wan-2.2-a14b-image-to-video-turbo": {
88
+ "duration_seconds": {
89
+ "type": "integer"
90
+ },
91
+ "first_frame_image_url": {
92
+ "required": True
93
+ },
94
+ "output_resolution": {
95
+ "enum": ["480p", "720p"]
96
+ },
97
+ "seed": {
98
+ "type": "integer"
99
+ }
100
+ },
101
+ "wan-2.5-image-to-video": {
102
+ "duration_seconds": {
103
+ "required": True,
104
+ "type": "integer"
105
+ },
106
+ "first_frame_image_url": {
107
+ "required": True
108
+ },
109
+ "output_resolution": {
110
+ "enum": ["720p", "1080p"]
111
+ },
112
+ "seed": {
113
+ "type": "integer"
114
+ }
115
+ },
116
+ "wan-2.6-flash-image-to-video": {
117
+ "audio": {
118
+ "required": True
119
+ },
120
+ "duration_seconds": {
121
+ "type": "integer"
122
+ },
123
+ "first_frame_image_url": {
124
+ "required": True
125
+ },
126
+ "output_resolution": {
127
+ "enum": ["720p", "1080p"]
128
+ },
129
+ "prompt": {
130
+ "required": True
131
+ },
132
+ "seed": {
133
+ "type": "integer"
134
+ }
135
+ },
136
+ "wan-2.6-image-to-video": {
137
+ "duration_seconds": {
138
+ "type": "integer"
139
+ },
140
+ "first_frame_image_url": {
141
+ "required": True
142
+ },
143
+ "output_resolution": {
144
+ "enum": ["720p", "1080p"]
145
+ },
146
+ "prompt": {
147
+ "required": True
148
+ },
149
+ "seed": {
150
+ "type": "integer"
151
+ }
152
+ },
153
+ "wan-2.7-image-to-video": {
154
+ "duration_seconds": {
155
+ "type": "integer"
156
+ },
157
+ "output_resolution": {
158
+ "enum": ["720p", "1080p"]
159
+ },
160
+ "prompt": {
161
+ "required": True
162
+ },
163
+ "seed": {
164
+ "type": "integer"
165
+ }
166
+ }
167
+ }
168
+ },
169
+ "speech-to-video": {
170
+ "models": ["wan-2.2-a14b-speech-to-video-turbo"],
171
+ "fields_by_model": {
172
+ "wan-2.2-a14b-speech-to-video-turbo": {
173
+ "frames_per_second": {
174
+ "type": "integer"
175
+ },
176
+ "num_frames": {
177
+ "type": "integer"
178
+ },
179
+ "num_inference_steps": {
180
+ "type": "integer"
181
+ },
182
+ "output_resolution": {
183
+ "enum": ["480p", "580p", "720p"]
184
+ },
185
+ "prompt": {
186
+ "required": True
187
+ },
188
+ "seed": {
189
+ "type": "integer"
190
+ },
191
+ "source_audio_url": {
192
+ "required": True
193
+ },
194
+ "source_image_url": {
195
+ "required": True
196
+ }
197
+ }
198
+ }
199
+ },
200
+ "text-to-image": {
201
+ "models": ["wan-2.7-image", "wan-2.7-image-pro"],
202
+ "fields_by_model": {
203
+ "wan-2.7-image": {
204
+ "aspect_ratio": {
205
+ "enum": ["1:1", "16:9", "4:3", "21:9", "3:4", "9:16", "8:1", "1:8"]
206
+ },
207
+ "output_count": {
208
+ "type": "integer"
209
+ },
210
+ "output_resolution": {
211
+ "enum": ["1k", "2k", "4k"]
212
+ },
213
+ "seed": {
214
+ "type": "integer"
215
+ }
216
+ },
217
+ "wan-2.7-image-pro": {
218
+ "aspect_ratio": {
219
+ "enum": ["1:1", "16:9", "4:3", "21:9", "3:4", "9:16", "8:1", "1:8"]
220
+ },
221
+ "output_count": {
222
+ "type": "integer"
223
+ },
224
+ "output_resolution": {
225
+ "enum": ["1k", "2k", "4k"]
226
+ },
227
+ "seed": {
228
+ "type": "integer"
229
+ }
230
+ }
231
+ }
232
+ },
233
+ "text-to-video": {
234
+ "models": ["wan-2.2-a14b-text-to-video-turbo", "wan-2.5-text-to-video", "wan-2.6-text-to-video", "wan-2.7-r2v", "wan-2.7-text-to-video"],
235
+ "fields_by_model": {
236
+ "wan-2.2-a14b-text-to-video-turbo": {
237
+ "duration_seconds": {
238
+ "type": "integer"
239
+ },
240
+ "output_resolution": {
241
+ "enum": ["480p", "580p", "720p"]
242
+ },
243
+ "seed": {
244
+ "type": "integer"
245
+ }
246
+ },
247
+ "wan-2.5-text-to-video": {
248
+ "duration_seconds": {
249
+ "type": "integer"
250
+ },
251
+ "output_resolution": {
252
+ "enum": ["720p", "1080p"]
253
+ },
254
+ "seed": {
255
+ "type": "integer"
256
+ }
257
+ },
258
+ "wan-2.6-text-to-video": {
259
+ "duration_seconds": {
260
+ "type": "integer"
261
+ },
262
+ "output_resolution": {
263
+ "enum": ["720p", "1080p"]
264
+ },
265
+ "seed": {
266
+ "type": "integer"
267
+ }
268
+ },
269
+ "wan-2.7-r2v": {
270
+ "aspect_ratio": {
271
+ "enum": ["16:9", "9:16", "1:1", "4:3", "3:4"]
272
+ },
273
+ "duration_seconds": {
274
+ "min": 2,
275
+ "max": 10,
276
+ "type": "integer"
277
+ },
278
+ "output_resolution": {
279
+ "enum": ["720p", "1080p"]
280
+ },
281
+ "seed": {
282
+ "type": "integer"
283
+ }
284
+ },
285
+ "wan-2.7-text-to-video": {
286
+ "duration_seconds": {
287
+ "type": "integer"
288
+ },
289
+ "output_resolution": {
290
+ "enum": ["720p", "1080p"]
291
+ },
292
+ "seed": {
293
+ "type": "integer"
294
+ }
295
+ }
296
+ }
297
+ }
298
+ }
File without changes
@@ -0,0 +1,15 @@
1
+ from .animate import Animate
2
+ from .edit_video import EditVideo
3
+ from .image_to_video import ImageToVideo
4
+ from .speech_to_video import SpeechToVideo
5
+ from .text_to_image import TextToImage
6
+ from .text_to_video import TextToVideo
7
+
8
+ __all__ = [
9
+ "TextToVideo",
10
+ "ImageToVideo",
11
+ "SpeechToVideo",
12
+ "Animate",
13
+ "TextToImage",
14
+ "EditVideo",
15
+ ]
@@ -0,0 +1,58 @@
1
+ """Wan animate resource."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from runapi.core import Resource
8
+
9
+ from ..contract_gen import CONTRACT
10
+ from ..types import (
11
+ CompletedVideoTaskResponse,
12
+ VideoTaskResponse,
13
+ )
14
+
15
+
16
+ class Animate(Resource):
17
+ """Animation operations (animate-move, animate-replace) with Wan models."""
18
+
19
+ ENDPOINT = "/api/v1/wan/animate"
20
+
21
+ RESPONSE_CLASS = VideoTaskResponse
22
+ COMPLETED_RESPONSE_CLASS = CompletedVideoTaskResponse
23
+
24
+ def run(self, **params: Any) -> Any:
25
+ """Transfer motion and poll until it completes.
26
+
27
+ Args:
28
+ **params: animation parameters (model, ...).
29
+
30
+ Returns:
31
+ The completed (narrowed) animation response.
32
+ """
33
+ task = self.create(**params)
34
+ return self._poll_until_complete(lambda: self.get(task.id))
35
+
36
+ def create(self, **params: Any) -> Any:
37
+ """Create a animation task and return immediately with an id.
38
+
39
+ Args:
40
+ **params: animation parameters (model, ...).
41
+
42
+ Returns:
43
+ The task creation result with an id.
44
+ """
45
+ compacted = self._compact_params(params)
46
+ self._validate_contract(CONTRACT["animate"], compacted)
47
+ return self._request("post", self.ENDPOINT, body=compacted)
48
+
49
+ def get(self, id: str) -> Any:
50
+ """Fetch the current status of a animation task.
51
+
52
+ Args:
53
+ id: The task id returned by ``create``.
54
+
55
+ Returns:
56
+ The current task status.
57
+ """
58
+ return self._request("get", f"{self.ENDPOINT}/{id}")
@@ -0,0 +1,58 @@
1
+ """Wan edit-video resource."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from runapi.core import Resource
8
+
9
+ from ..contract_gen import CONTRACT
10
+ from ..types import (
11
+ CompletedVideoTaskResponse,
12
+ VideoTaskResponse,
13
+ )
14
+
15
+
16
+ class EditVideo(Resource):
17
+ """Edit videos with Wan models."""
18
+
19
+ ENDPOINT = "/api/v1/wan/edit_video"
20
+
21
+ RESPONSE_CLASS = VideoTaskResponse
22
+ COMPLETED_RESPONSE_CLASS = CompletedVideoTaskResponse
23
+
24
+ def run(self, **params: Any) -> Any:
25
+ """Edit a video and poll until it completes.
26
+
27
+ Args:
28
+ **params: video edit parameters (model, ...).
29
+
30
+ Returns:
31
+ The completed (narrowed) video edit response.
32
+ """
33
+ task = self.create(**params)
34
+ return self._poll_until_complete(lambda: self.get(task.id))
35
+
36
+ def create(self, **params: Any) -> Any:
37
+ """Create a video edit task and return immediately with an id.
38
+
39
+ Args:
40
+ **params: video edit parameters (model, ...).
41
+
42
+ Returns:
43
+ The task creation result with an id.
44
+ """
45
+ compacted = self._compact_params(params)
46
+ self._validate_contract(CONTRACT["edit-video"], compacted)
47
+ return self._request("post", self.ENDPOINT, body=compacted)
48
+
49
+ def get(self, id: str) -> Any:
50
+ """Fetch the current status of a video edit task.
51
+
52
+ Args:
53
+ id: The task id returned by ``create``.
54
+
55
+ Returns:
56
+ The current task status.
57
+ """
58
+ return self._request("get", f"{self.ENDPOINT}/{id}")
@@ -0,0 +1,58 @@
1
+ """Wan image-to-video resource."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from runapi.core import Resource
8
+
9
+ from ..contract_gen import CONTRACT
10
+ from ..types import (
11
+ CompletedVideoTaskResponse,
12
+ VideoTaskResponse,
13
+ )
14
+
15
+
16
+ class ImageToVideo(Resource):
17
+ """Generate videos from a source image with Wan models."""
18
+
19
+ ENDPOINT = "/api/v1/wan/image_to_video"
20
+
21
+ RESPONSE_CLASS = VideoTaskResponse
22
+ COMPLETED_RESPONSE_CLASS = CompletedVideoTaskResponse
23
+
24
+ def run(self, **params: Any) -> Any:
25
+ """Generate a video from an image and poll until it completes.
26
+
27
+ Args:
28
+ **params: image-to-video parameters (model, ...).
29
+
30
+ Returns:
31
+ The completed (narrowed) image-to-video response.
32
+ """
33
+ task = self.create(**params)
34
+ return self._poll_until_complete(lambda: self.get(task.id))
35
+
36
+ def create(self, **params: Any) -> Any:
37
+ """Create an image-to-video task and return immediately with an id.
38
+
39
+ Args:
40
+ **params: image-to-video parameters (model, ...).
41
+
42
+ Returns:
43
+ The task creation result with an id.
44
+ """
45
+ compacted = self._compact_params(params)
46
+ self._validate_contract(CONTRACT["image-to-video"], compacted)
47
+ return self._request("post", self.ENDPOINT, body=compacted)
48
+
49
+ def get(self, id: str) -> Any:
50
+ """Fetch the current status of an image-to-video task.
51
+
52
+ Args:
53
+ id: The task id returned by ``create``.
54
+
55
+ Returns:
56
+ The current task status.
57
+ """
58
+ return self._request("get", f"{self.ENDPOINT}/{id}")
@@ -0,0 +1,58 @@
1
+ """Wan speech-to-video resource."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from runapi.core import Resource
8
+
9
+ from ..contract_gen import CONTRACT
10
+ from ..types import (
11
+ CompletedVideoTaskResponse,
12
+ VideoTaskResponse,
13
+ )
14
+
15
+
16
+ class SpeechToVideo(Resource):
17
+ """Generate speech-driven talking-head videos with Wan models."""
18
+
19
+ ENDPOINT = "/api/v1/wan/speech_to_video"
20
+
21
+ RESPONSE_CLASS = VideoTaskResponse
22
+ COMPLETED_RESPONSE_CLASS = CompletedVideoTaskResponse
23
+
24
+ def run(self, **params: Any) -> Any:
25
+ """Generate a lip-synced video and poll until it completes.
26
+
27
+ Args:
28
+ **params: speech-to-video parameters (model, ...).
29
+
30
+ Returns:
31
+ The completed (narrowed) speech-to-video response.
32
+ """
33
+ task = self.create(**params)
34
+ return self._poll_until_complete(lambda: self.get(task.id))
35
+
36
+ def create(self, **params: Any) -> Any:
37
+ """Create a speech-to-video task and return immediately with an id.
38
+
39
+ Args:
40
+ **params: speech-to-video parameters (model, ...).
41
+
42
+ Returns:
43
+ The task creation result with an id.
44
+ """
45
+ compacted = self._compact_params(params)
46
+ self._validate_contract(CONTRACT["speech-to-video"], compacted)
47
+ return self._request("post", self.ENDPOINT, body=compacted)
48
+
49
+ def get(self, id: str) -> Any:
50
+ """Fetch the current status of a speech-to-video task.
51
+
52
+ Args:
53
+ id: The task id returned by ``create``.
54
+
55
+ Returns:
56
+ The current task status.
57
+ """
58
+ return self._request("get", f"{self.ENDPOINT}/{id}")
@@ -0,0 +1,60 @@
1
+ """Wan text-to-image resource."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from runapi.core import Resource, ValidationError
8
+
9
+ from ..contract_gen import CONTRACT
10
+ from ..types import (
11
+ CompletedImageTaskResponse,
12
+ ImageTaskResponse,
13
+ )
14
+
15
+
16
+ class TextToImage(Resource):
17
+ """Generate images from text prompts with Wan models."""
18
+
19
+ ENDPOINT = "/api/v1/wan/text_to_image"
20
+
21
+ RESPONSE_CLASS = ImageTaskResponse
22
+ COMPLETED_RESPONSE_CLASS = CompletedImageTaskResponse
23
+
24
+ def run(self, **params: Any) -> Any:
25
+ """Generate an image and poll until it completes.
26
+
27
+ Args:
28
+ **params: text-to-image parameters (model, ...).
29
+
30
+ Returns:
31
+ The completed (narrowed) text-to-image response.
32
+ """
33
+ task = self.create(**params)
34
+ return self._poll_until_complete(lambda: self.get(task.id))
35
+
36
+ def create(self, **params: Any) -> Any:
37
+ """Create a text-to-image task and return immediately with an id.
38
+
39
+ Args:
40
+ **params: text-to-image parameters (model, ...).
41
+
42
+ Returns:
43
+ The task creation result with an id.
44
+ """
45
+ compacted = self._compact_params(params)
46
+ self._validate_contract(CONTRACT["text-to-image"], compacted)
47
+ if not compacted.get("prompt"):
48
+ raise ValidationError("prompt is required")
49
+ return self._request("post", self.ENDPOINT, body=compacted)
50
+
51
+ def get(self, id: str) -> Any:
52
+ """Fetch the current status of a text-to-image task.
53
+
54
+ Args:
55
+ id: The task id returned by ``create``.
56
+
57
+ Returns:
58
+ The current task status.
59
+ """
60
+ return self._request("get", f"{self.ENDPOINT}/{id}")
@@ -0,0 +1,60 @@
1
+ """Wan text-to-video resource."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from runapi.core import Resource, ValidationError
8
+
9
+ from ..contract_gen import CONTRACT
10
+ from ..types import (
11
+ CompletedVideoTaskResponse,
12
+ VideoTaskResponse,
13
+ )
14
+
15
+
16
+ class TextToVideo(Resource):
17
+ """Generate videos from text prompts with Wan models."""
18
+
19
+ ENDPOINT = "/api/v1/wan/text_to_video"
20
+
21
+ RESPONSE_CLASS = VideoTaskResponse
22
+ COMPLETED_RESPONSE_CLASS = CompletedVideoTaskResponse
23
+
24
+ def run(self, **params: Any) -> Any:
25
+ """Generate a video and poll until it completes.
26
+
27
+ Args:
28
+ **params: text-to-video parameters (model, ...).
29
+
30
+ Returns:
31
+ The completed (narrowed) text-to-video response.
32
+ """
33
+ task = self.create(**params)
34
+ return self._poll_until_complete(lambda: self.get(task.id))
35
+
36
+ def create(self, **params: Any) -> Any:
37
+ """Create a text-to-video task and return immediately with an id.
38
+
39
+ Args:
40
+ **params: text-to-video parameters (model, ...).
41
+
42
+ Returns:
43
+ The task creation result with an id.
44
+ """
45
+ compacted = self._compact_params(params)
46
+ self._validate_contract(CONTRACT["text-to-video"], compacted)
47
+ if not compacted.get("prompt"):
48
+ raise ValidationError("prompt is required")
49
+ return self._request("post", self.ENDPOINT, body=compacted)
50
+
51
+ def get(self, id: str) -> Any:
52
+ """Fetch the current status of a text-to-video task.
53
+
54
+ Args:
55
+ id: The task id returned by ``create``.
56
+
57
+ Returns:
58
+ The current task status.
59
+ """
60
+ return self._request("get", f"{self.ENDPOINT}/{id}")
@@ -0,0 +1,43 @@
1
+ """Wan model lists, enums, and response models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from runapi.core import BaseModel, TaskResponse, optional, required
6
+
7
+
8
+ class Video(BaseModel):
9
+ url = optional(str)
10
+
11
+
12
+ class Image(BaseModel):
13
+ url = optional(str)
14
+
15
+
16
+ class VideoTaskResponse(TaskResponse):
17
+ """Wan video task status response."""
18
+
19
+ id = required(str)
20
+ status = optional(str, enum=lambda: TaskResponse.Status.ALL)
21
+ videos = optional([lambda: Video])
22
+ error = optional(str)
23
+
24
+
25
+ class ImageTaskResponse(TaskResponse):
26
+ """Wan image task status response."""
27
+
28
+ id = required(str)
29
+ status = optional(str, enum=lambda: TaskResponse.Status.ALL)
30
+ images = optional([lambda: Image])
31
+ error = optional(str)
32
+
33
+
34
+ class CompletedVideoTaskResponse(VideoTaskResponse):
35
+ """Narrowed response from ``run()`` once polling observes completion."""
36
+
37
+ videos = required([lambda: Video])
38
+
39
+
40
+ class CompletedImageTaskResponse(ImageTaskResponse):
41
+ """Narrowed response from ``run()`` once polling observes completion."""
42
+
43
+ images = required([lambda: Image])
@@ -0,0 +1,199 @@
1
+ import pytest
2
+
3
+ from runapi.core import config
4
+ from runapi.core.errors import AuthenticationError, ValidationError
5
+ from runapi.wan import WanClient
6
+ from runapi.wan.resources.animate import Animate
7
+ from runapi.wan.resources.edit_video import EditVideo
8
+ from runapi.wan.resources.image_to_video import ImageToVideo
9
+ from runapi.wan.resources.speech_to_video import SpeechToVideo
10
+ from runapi.wan.resources.text_to_image import TextToImage
11
+ from runapi.wan.resources.text_to_video import TextToVideo
12
+ from runapi.wan.types import (
13
+ CompletedVideoTaskResponse,
14
+ VideoTaskResponse,
15
+ )
16
+
17
+
18
+ class FakeHttp:
19
+ def __init__(self, *responses):
20
+ self._responses = list(responses)
21
+ self.calls = []
22
+
23
+ def request(self, method, path, body=None, options=None):
24
+ self.calls.append((method, path, body))
25
+ if self._responses:
26
+ return self._responses.pop(0)
27
+ return {"id": "task_1", "status": "pending"}
28
+
29
+
30
+ @pytest.fixture(autouse=True)
31
+ def reset_config(monkeypatch):
32
+ monkeypatch.delenv("RUNAPI_API_KEY", raising=False)
33
+ monkeypatch.setattr(config, "api_key", None)
34
+ yield
35
+
36
+
37
+ # --- auth -----------------------------------------------------------------
38
+
39
+
40
+ def test_accepts_api_key_parameter():
41
+ assert isinstance(WanClient(api_key="k", http_client=FakeHttp()), WanClient)
42
+
43
+
44
+ def test_falls_back_to_global(monkeypatch):
45
+ monkeypatch.setattr(config, "api_key", "global-key")
46
+ assert isinstance(WanClient(http_client=FakeHttp()), WanClient)
47
+
48
+
49
+ def test_falls_back_to_env(monkeypatch):
50
+ monkeypatch.setenv("RUNAPI_API_KEY", "env-key")
51
+ assert isinstance(WanClient(http_client=FakeHttp()), WanClient)
52
+
53
+
54
+ def test_raises_without_api_key():
55
+ with pytest.raises(AuthenticationError, match="API key is required"):
56
+ WanClient()
57
+
58
+
59
+ # --- injection / accessors ------------------------------------------------
60
+
61
+
62
+ def test_uses_injected_http_client():
63
+ fake = FakeHttp()
64
+ client = WanClient(api_key="k", http_client=fake)
65
+ assert client.text_to_video._http is fake
66
+ assert client.image_to_video._http is fake
67
+ assert client.speech_to_video._http is fake
68
+ assert client.animate._http is fake
69
+ assert client.text_to_image._http is fake
70
+ assert client.edit_video._http is fake
71
+
72
+
73
+ def test_exposes_resource_accessors():
74
+ client = WanClient(api_key="k", http_client=FakeHttp())
75
+ assert isinstance(client.text_to_video, TextToVideo)
76
+ assert isinstance(client.image_to_video, ImageToVideo)
77
+ assert isinstance(client.speech_to_video, SpeechToVideo)
78
+ assert isinstance(client.animate, Animate)
79
+ assert isinstance(client.text_to_image, TextToImage)
80
+ assert isinstance(client.edit_video, EditVideo)
81
+
82
+
83
+ # --- request shapes -------------------------------------------------------
84
+
85
+
86
+ def test_create_posts_compacted_body():
87
+ fake = FakeHttp({"id": "t1", "status": "pending"})
88
+ client = WanClient(api_key="k", http_client=fake)
89
+ result = client.text_to_video.create(
90
+ model="wan-2.6-text-to-video", prompt="hello world", seed=None
91
+ )
92
+ assert fake.calls == [
93
+ ("post", "/api/v1/wan/text_to_video", {"model": "wan-2.6-text-to-video", "prompt": "hello world"}),
94
+ ]
95
+ assert isinstance(result, VideoTaskResponse)
96
+
97
+
98
+ def test_get_fetches_by_id():
99
+ fake = FakeHttp({"id": "t1", "status": "processing"})
100
+ client = WanClient(api_key="k", http_client=fake)
101
+ client.text_to_video.get("t1")
102
+ assert fake.calls == [("get", "/api/v1/wan/text_to_video/t1", None)]
103
+
104
+
105
+ def test_image_to_video_posts_to_endpoint():
106
+ fake = FakeHttp({"id": "t1", "status": "pending"})
107
+ client = WanClient(api_key="k", http_client=fake)
108
+ client.image_to_video.create(
109
+ model="wan-2.6-image-to-video",
110
+ first_frame_image_url="https://x/a.png",
111
+ prompt="make it move",
112
+ )
113
+ assert fake.calls == [
114
+ (
115
+ "post",
116
+ "/api/v1/wan/image_to_video",
117
+ {"model": "wan-2.6-image-to-video", "first_frame_image_url": "https://x/a.png", "prompt": "make it move"},
118
+ ),
119
+ ]
120
+
121
+
122
+ def test_text_to_image_uses_image_endpoint():
123
+ fake = FakeHttp({"id": "t1", "status": "pending"})
124
+ client = WanClient(api_key="k", http_client=fake)
125
+ client.text_to_image.create(model="wan-2.7-image", prompt="a cat")
126
+ assert fake.calls == [
127
+ ("post", "/api/v1/wan/text_to_image", {"model": "wan-2.7-image", "prompt": "a cat"}),
128
+ ]
129
+
130
+
131
+ def test_run_narrows_completed_type():
132
+ fake = FakeHttp(
133
+ {"id": "t1", "status": "pending"},
134
+ {"id": "t1", "status": "completed", "videos": [{"url": "https://x/y.mp4"}]},
135
+ )
136
+ client = WanClient(api_key="k", http_client=fake)
137
+ result = client.text_to_video.run(model="wan-2.6-text-to-video", prompt="a serene lake")
138
+ assert isinstance(result, CompletedVideoTaskResponse)
139
+ assert result.videos[0].url == "https://x/y.mp4"
140
+
141
+
142
+ # --- validation -----------------------------------------------------------
143
+
144
+
145
+ def test_rejects_unknown_model():
146
+ client = WanClient(api_key="k", http_client=FakeHttp())
147
+ with pytest.raises(ValidationError, match="model must be one of"):
148
+ client.text_to_video.create(model="nope", prompt="hi there")
149
+
150
+
151
+ def test_requires_prompt():
152
+ client = WanClient(api_key="k", http_client=FakeHttp())
153
+ with pytest.raises(ValidationError, match="prompt is required"):
154
+ client.text_to_video.create(model="wan-2.6-text-to-video")
155
+
156
+
157
+ def test_animate_requires_source_and_reference():
158
+ client = WanClient(api_key="k", http_client=FakeHttp())
159
+ with pytest.raises(ValidationError, match="reference_video_url is required"):
160
+ client.animate.create(model="wan-2.2-animate-move")
161
+ with pytest.raises(ValidationError, match="source_image_url is required"):
162
+ client.animate.create(model="wan-2.2-animate-move", reference_video_url="https://x/v.mp4")
163
+
164
+
165
+ def test_speech_to_video_requires_image_and_audio():
166
+ client = WanClient(api_key="k", http_client=FakeHttp())
167
+ with pytest.raises(ValidationError, match="source_audio_url is required"):
168
+ client.speech_to_video.create(
169
+ model="wan-2.2-a14b-speech-to-video-turbo", prompt="say hi"
170
+ )
171
+ with pytest.raises(ValidationError, match="source_image_url is required"):
172
+ client.speech_to_video.create(
173
+ model="wan-2.2-a14b-speech-to-video-turbo",
174
+ prompt="say hi",
175
+ source_audio_url="https://x/a.mp3",
176
+ )
177
+
178
+
179
+ def test_edit_video_2_6_requires_prompt_and_source_urls():
180
+ client = WanClient(api_key="k", http_client=FakeHttp())
181
+ with pytest.raises(ValidationError, match="prompt is required"):
182
+ client.edit_video.create(model="wan-2.6-edit-video")
183
+ with pytest.raises(ValidationError, match="source_video_urls is required"):
184
+ client.edit_video.create(model="wan-2.6-edit-video", prompt="trim it")
185
+
186
+
187
+ def test_edit_video_non_2_6_requires_single_source_url():
188
+ client = WanClient(api_key="k", http_client=FakeHttp())
189
+ with pytest.raises(ValidationError, match="source_video_url is required"):
190
+ client.edit_video.create(model="wan-2.7-edit-video")
191
+
192
+
193
+ def test_edit_video_non_2_6_accepts_single_source_url():
194
+ fake = FakeHttp({"id": "t1", "status": "pending"})
195
+ client = WanClient(api_key="k", http_client=fake)
196
+ client.edit_video.create(model="wan-2.7-edit-video", source_video_url="https://x/v.mp4")
197
+ assert fake.calls == [
198
+ ("post", "/api/v1/wan/edit_video", {"model": "wan-2.7-edit-video", "source_video_url": "https://x/v.mp4"}),
199
+ ]