runapi-seedance 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,81 @@
1
+ Metadata-Version: 2.4
2
+ Name: runapi-seedance
3
+ Version: 0.1.0
4
+ Summary: Seedance video generation client for RunAPI
5
+ Project-URL: Homepage, https://runapi.ai/models/seedance
6
+ Project-URL: Documentation, https://runapi.ai/docs#sdk-seedance
7
+ Author-email: RunAPI <contact@runapi.ai>
8
+ License-Expression: Apache-2.0
9
+ Keywords: ai,runapi,sdk,seedance,text-to-video,video
10
+ Requires-Python: >=3.9
11
+ Requires-Dist: runapi-core
12
+ Description-Content-Type: text/markdown
13
+
14
+ # Seedance Python SDK for RunAPI
15
+
16
+ The Seedance Python SDK is the language-specific package for Seedance on RunAPI.
17
+ Use it for text-to-video, image-to-video, and reference-media flows when your
18
+ application needs JSON request bodies, task status lookup, and consistent RunAPI
19
+ errors in Python.
20
+
21
+ For model details, use https://runapi.ai/models/seedance; for API reference, use
22
+ https://runapi.ai/docs#seedance; for SDK docs, use https://runapi.ai/docs#sdk-seedance.
23
+
24
+ ## Install
25
+
26
+ ```bash
27
+ pip install runapi-seedance
28
+ ```
29
+
30
+ ## Quick start
31
+
32
+ ```python
33
+ from runapi.seedance import SeedanceClient
34
+
35
+ client = SeedanceClient() # reads RUNAPI_API_KEY, or pass api_key="sk-..."
36
+
37
+ task = client.text_to_video.create(
38
+ model="seedance-2.0",
39
+ prompt="A cat walking through a garden",
40
+ duration_seconds=8,
41
+ )
42
+ status = client.text_to_video.get(task.id)
43
+ ```
44
+
45
+ Use `create` to submit a task and return quickly, `get` to fetch the latest task
46
+ state, and `run` to create and poll until completion:
47
+
48
+ ```python
49
+ result = client.text_to_video.run(
50
+ model="seedance-2.0",
51
+ prompt="A cinematic drone shot over snowy mountains",
52
+ duration_seconds=8,
53
+ )
54
+ print(result.videos[0].url)
55
+ ```
56
+
57
+ In web request handlers, prefer `create` plus webhook or later `get` polling so a
58
+ worker is not held open.
59
+
60
+ RunAPI-generated file URLs are temporary. Download and store generated videos in
61
+ your own durable storage within 7 days; do not treat returned URLs as long-term
62
+ assets.
63
+
64
+ ## Language notes
65
+
66
+ Pass parameters as keyword arguments and catch the `runapi.seedance` error
67
+ classes when building video jobs or scripts. The available resource is
68
+ `text_to_video`. Keep `RUNAPI_API_KEY` in the environment or your secret
69
+ manager; never commit API keys or callback secrets.
70
+
71
+ ## Links
72
+
73
+ - Model page: https://runapi.ai/models/seedance
74
+ - SDK docs: https://runapi.ai/docs#sdk-seedance
75
+ - Product docs: https://runapi.ai/docs#seedance
76
+ - Pricing and rate limits: https://runapi.ai/models/seedance
77
+ - Full catalog: https://runapi.ai/models
78
+
79
+ ## License
80
+
81
+ Licensed under the Apache License, Version 2.0.
@@ -0,0 +1,68 @@
1
+ # Seedance Python SDK for RunAPI
2
+
3
+ The Seedance Python SDK is the language-specific package for Seedance on RunAPI.
4
+ Use it for text-to-video, image-to-video, and reference-media flows when your
5
+ application needs JSON request bodies, task status lookup, and consistent RunAPI
6
+ errors in Python.
7
+
8
+ For model details, use https://runapi.ai/models/seedance; for API reference, use
9
+ https://runapi.ai/docs#seedance; for SDK docs, use https://runapi.ai/docs#sdk-seedance.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pip install runapi-seedance
15
+ ```
16
+
17
+ ## Quick start
18
+
19
+ ```python
20
+ from runapi.seedance import SeedanceClient
21
+
22
+ client = SeedanceClient() # reads RUNAPI_API_KEY, or pass api_key="sk-..."
23
+
24
+ task = client.text_to_video.create(
25
+ model="seedance-2.0",
26
+ prompt="A cat walking through a garden",
27
+ duration_seconds=8,
28
+ )
29
+ status = client.text_to_video.get(task.id)
30
+ ```
31
+
32
+ Use `create` to submit a task and return quickly, `get` to fetch the latest task
33
+ state, and `run` to create and poll until completion:
34
+
35
+ ```python
36
+ result = client.text_to_video.run(
37
+ model="seedance-2.0",
38
+ prompt="A cinematic drone shot over snowy mountains",
39
+ duration_seconds=8,
40
+ )
41
+ print(result.videos[0].url)
42
+ ```
43
+
44
+ In web request handlers, prefer `create` plus webhook or later `get` polling so a
45
+ worker is not held open.
46
+
47
+ RunAPI-generated file URLs are temporary. Download and store generated videos in
48
+ your own durable storage within 7 days; do not treat returned URLs as long-term
49
+ assets.
50
+
51
+ ## Language notes
52
+
53
+ Pass parameters as keyword arguments and catch the `runapi.seedance` error
54
+ classes when building video jobs or scripts. The available resource is
55
+ `text_to_video`. Keep `RUNAPI_API_KEY` in the environment or your secret
56
+ manager; never commit API keys or callback secrets.
57
+
58
+ ## Links
59
+
60
+ - Model page: https://runapi.ai/models/seedance
61
+ - SDK docs: https://runapi.ai/docs#sdk-seedance
62
+ - Product docs: https://runapi.ai/docs#seedance
63
+ - Pricing and rate limits: https://runapi.ai/models/seedance
64
+ - Full catalog: https://runapi.ai/models
65
+
66
+ ## License
67
+
68
+ 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-seedance"
7
+ version = "0.1.0"
8
+ description = "Seedance 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", "seedance", "text-to-video", "video", "ai", "sdk"]
14
+ dependencies = ["runapi-core"]
15
+
16
+ [project.urls]
17
+ Homepage = "https://runapi.ai/models/seedance"
18
+ Documentation = "https://runapi.ai/docs#sdk-seedance"
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 = "seedance"
@@ -0,0 +1,24 @@
1
+ """Seedance 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 SeedanceClient
14
+
15
+ __all__ = [
16
+ "SeedanceClient",
17
+ "AuthenticationError",
18
+ "RateLimitError",
19
+ "InsufficientCreditsError",
20
+ "NotFoundError",
21
+ "ValidationError",
22
+ "TaskFailedError",
23
+ "TaskTimeoutError",
24
+ ]
@@ -0,0 +1,27 @@
1
+ """Seedance 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.text_to_video import TextToVideo
10
+
11
+
12
+ class SeedanceClient:
13
+ """Seedance video generation client.
14
+
15
+ Example::
16
+
17
+ client = SeedanceClient(api_key="sk-...")
18
+ result = client.text_to_video.run(
19
+ model="seedance-2.0", prompt="A cat walking through a garden"
20
+ )
21
+ """
22
+
23
+ def __init__(self, api_key: Optional[str] = None, **options: Any) -> None:
24
+ resolved_api_key = resolve_api_key(api_key)
25
+ client_options = ClientOptions(api_key=resolved_api_key, **options)
26
+ http = client_options.http_client or HttpClient(client_options)
27
+ self.text_to_video = TextToVideo(http)
@@ -0,0 +1,104 @@
1
+ CONTRACT = {
2
+ "text-to-video": {
3
+ "models": ["seedance-1.5-pro", "seedance-2.0", "seedance-2.0-fast", "seedance-v1-lite", "seedance-v1-pro", "seedance-v1-pro-fast"],
4
+ "fields_by_model": {
5
+ "seedance-1.5-pro": {
6
+ "aspect_ratio": {
7
+ "enum": ["1:1", "4:3", "3:4", "16:9", "9:16", "21:9"]
8
+ },
9
+ "duration_seconds": {
10
+ "required": True,
11
+ "min": 4,
12
+ "max": 12,
13
+ "type": "integer"
14
+ },
15
+ "output_resolution": {
16
+ "enum": ["480p", "720p", "1080p"]
17
+ },
18
+ "seed": {
19
+ "type": "integer"
20
+ }
21
+ },
22
+ "seedance-2.0": {
23
+ "aspect_ratio": {
24
+ "enum": ["1:1", "4:3", "3:4", "16:9", "9:16", "21:9", "auto"]
25
+ },
26
+ "duration_seconds": {
27
+ "min": 4,
28
+ "max": 15,
29
+ "type": "integer"
30
+ },
31
+ "output_resolution": {
32
+ "enum": ["480p", "720p", "1080p"]
33
+ },
34
+ "seed": {
35
+ "type": "integer"
36
+ }
37
+ },
38
+ "seedance-2.0-fast": {
39
+ "aspect_ratio": {
40
+ "enum": ["1:1", "4:3", "3:4", "16:9", "9:16", "21:9", "auto"]
41
+ },
42
+ "duration_seconds": {
43
+ "min": 4,
44
+ "max": 15,
45
+ "type": "integer"
46
+ },
47
+ "output_resolution": {
48
+ "enum": ["480p", "720p"]
49
+ },
50
+ "seed": {
51
+ "type": "integer"
52
+ }
53
+ },
54
+ "seedance-v1-lite": {
55
+ "aspect_ratio": {
56
+ "enum": ["1:1", "4:3", "3:4", "16:9", "9:16", "9:21"]
57
+ },
58
+ "duration_seconds": {
59
+ "enum": [5, 10],
60
+ "required": True,
61
+ "type": "integer"
62
+ },
63
+ "output_resolution": {
64
+ "enum": ["480p", "720p", "1080p"]
65
+ },
66
+ "seed": {
67
+ "type": "integer"
68
+ }
69
+ },
70
+ "seedance-v1-pro": {
71
+ "aspect_ratio": {
72
+ "enum": ["1:1", "4:3", "3:4", "16:9", "9:16", "21:9"]
73
+ },
74
+ "duration_seconds": {
75
+ "enum": [5, 10],
76
+ "required": True,
77
+ "type": "integer"
78
+ },
79
+ "output_resolution": {
80
+ "enum": ["480p", "720p", "1080p"]
81
+ },
82
+ "seed": {
83
+ "type": "integer"
84
+ }
85
+ },
86
+ "seedance-v1-pro-fast": {
87
+ "duration_seconds": {
88
+ "enum": [5, 10],
89
+ "required": True,
90
+ "type": "integer"
91
+ },
92
+ "first_frame_image_url": {
93
+ "required": True
94
+ },
95
+ "output_resolution": {
96
+ "enum": ["720p", "1080p"]
97
+ },
98
+ "seed": {
99
+ "type": "integer"
100
+ }
101
+ }
102
+ }
103
+ }
104
+ }
File without changes
@@ -0,0 +1,3 @@
1
+ from .text_to_video import TextToVideo
2
+
3
+ __all__ = ["TextToVideo"]
@@ -0,0 +1,170 @@
1
+ """Seedance 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 ..contract_gen import CONTRACT
10
+ from ..types import (
11
+ FRAME_FIELDS,
12
+ PROMPT_MAX_LENGTH_1_5,
13
+ PROMPT_MAX_LENGTH_2,
14
+ PROMPT_MAX_LENGTH_V1,
15
+ PROMPT_MIN_LENGTH,
16
+ REFERENCE_FIELDS,
17
+ SEED_RANGE,
18
+ V1_MODELS,
19
+ CompletedTextToVideoResponse,
20
+ TextToVideoResponse,
21
+ )
22
+
23
+
24
+ class TextToVideo(Resource):
25
+ """Generate videos from text prompts, images, or reference media."""
26
+
27
+ ENDPOINT = "/api/v1/seedance/text_to_video"
28
+
29
+ RESPONSE_CLASS = TextToVideoResponse
30
+ COMPLETED_RESPONSE_CLASS = CompletedTextToVideoResponse
31
+
32
+ def run(self, **params: Any) -> Any:
33
+ """Generate a video and poll until it completes.
34
+
35
+ Args:
36
+ **params: video generation parameters (model, ...).
37
+
38
+ Returns:
39
+ The completed (narrowed) video generation response.
40
+ """
41
+ task = self.create(**params)
42
+ return self._poll_until_complete(lambda: self.get(task.id))
43
+
44
+ def create(self, **params: Any) -> Any:
45
+ """Create a video generation task and return immediately with an id.
46
+
47
+ Args:
48
+ **params: video generation parameters (model, ...).
49
+
50
+ Returns:
51
+ The task creation result with an id.
52
+ """
53
+ compacted = self._compact_params(params)
54
+ self._validate_params(compacted)
55
+ return self._request("post", self.ENDPOINT, body=compacted)
56
+
57
+ def get(self, id: str) -> Any:
58
+ """Fetch the current status of a video generation task.
59
+
60
+ Args:
61
+ id: The task id returned by ``create``.
62
+
63
+ Returns:
64
+ The current task status.
65
+ """
66
+ return self._request("get", f"{self.ENDPOINT}/{id}")
67
+
68
+ def _validate_params(self, params: Dict[str, Any]) -> None:
69
+ prompt = params.get("prompt")
70
+ if not prompt:
71
+ raise ValidationError("prompt is required")
72
+
73
+ self._validate_contract(CONTRACT["text-to-video"], params)
74
+
75
+ model = params.get("model")
76
+ if model == "seedance-1.5-pro":
77
+ max_prompt = PROMPT_MAX_LENGTH_1_5
78
+ elif model in V1_MODELS:
79
+ max_prompt = PROMPT_MAX_LENGTH_V1
80
+ else:
81
+ max_prompt = PROMPT_MAX_LENGTH_2
82
+ if not (PROMPT_MIN_LENGTH <= len(prompt) <= max_prompt):
83
+ raise ValidationError(
84
+ f"prompt length must be between {PROMPT_MIN_LENGTH} and {max_prompt} characters"
85
+ )
86
+
87
+ if model == "seedance-1.5-pro":
88
+ self._validate_1_5_pro(params)
89
+ elif model in V1_MODELS:
90
+ self._validate_v1(params)
91
+ else:
92
+ self._validate_2(params)
93
+
94
+ def _validate_v1(self, params: Dict[str, Any]) -> None:
95
+ model = params.get("model")
96
+ has_image = self._field_present(params, "first_frame_image_url")
97
+
98
+ if has_image and self._field_present(params, "aspect_ratio"):
99
+ raise ValidationError(
100
+ "aspect_ratio is not accepted in image-to-video mode; it is derived from the image"
101
+ )
102
+
103
+ if self._field_present(params, "last_frame_image_url") and not (
104
+ model == "seedance-v1-lite" and has_image
105
+ ):
106
+ raise ValidationError(
107
+ "last_frame_image_url is only supported by seedance-v1-lite in image-to-video mode"
108
+ )
109
+
110
+ unsupported = [
111
+ "source_image_urls",
112
+ "reference_image_urls",
113
+ "reference_video_urls",
114
+ "reference_audio_urls",
115
+ "web_search",
116
+ "generate_audio",
117
+ ]
118
+ self._reject_unsupported(params, unsupported, model)
119
+
120
+ if model == "seedance-v1-pro-fast":
121
+ self._reject_unsupported(params, ["lock_camera", "seed"], model)
122
+
123
+ seed = params.get("seed")
124
+ if seed is not None:
125
+ if not (isinstance(seed, int) and not isinstance(seed, bool) and seed in SEED_RANGE):
126
+ raise ValidationError(
127
+ f"seed must be an integer between {SEED_RANGE.start} and {SEED_RANGE.stop - 1}"
128
+ )
129
+
130
+ def _validate_1_5_pro(self, params: Dict[str, Any]) -> None:
131
+ value = params.get("source_image_urls")
132
+ if isinstance(value, list) and len(value) > 2:
133
+ raise ValidationError("source_image_urls accepts at most 2 images for seedance-1.5-pro")
134
+
135
+ unsupported = [
136
+ "first_frame_image_url",
137
+ "last_frame_image_url",
138
+ "reference_image_urls",
139
+ "reference_video_urls",
140
+ "reference_audio_urls",
141
+ "web_search",
142
+ ]
143
+ self._reject_unsupported(params, unsupported, "seedance-1.5-pro")
144
+
145
+ def _validate_2(self, params: Dict[str, Any]) -> None:
146
+ unsupported = ["source_image_urls", "lock_camera"]
147
+ self._reject_unsupported(params, unsupported, params.get("model"))
148
+
149
+ self._validate_mode_conflicts(params)
150
+
151
+ def _validate_mode_conflicts(self, params: Dict[str, Any]) -> None:
152
+ has_frame = any(self._field_present(params, f) for f in FRAME_FIELDS)
153
+ has_reference = any(self._field_present(params, f) for f in REFERENCE_FIELDS)
154
+
155
+ if has_frame and has_reference:
156
+ raise ValidationError("Cannot use frame mode and reference mode at the same time")
157
+
158
+ def _reject_unsupported(self, params: Dict[str, Any], fields: Any, model: Any) -> None:
159
+ for field in fields:
160
+ if self._field_present(params, field):
161
+ raise ValidationError(f"{field} is not supported for {model}")
162
+
163
+ @staticmethod
164
+ def _field_present(params: Dict[str, Any], key: str) -> bool:
165
+ value = params.get(key)
166
+ if value is None:
167
+ return False
168
+ if hasattr(value, "__len__"):
169
+ return len(value) > 0
170
+ return True
@@ -0,0 +1,42 @@
1
+ """Seedance model lists, enums, and response models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from runapi.core import BaseModel, TaskResponse, optional, required
6
+
7
+ V1_MODELS = ["seedance-v1-lite", "seedance-v1-pro", "seedance-v1-pro-fast"]
8
+
9
+ SEED_RANGE = range(-1, 2_147_483_648)
10
+
11
+ PROMPT_MIN_LENGTH = 3
12
+ PROMPT_MAX_LENGTH_1_5 = 2500
13
+ PROMPT_MAX_LENGTH_2 = 20000
14
+ PROMPT_MAX_LENGTH_V1 = 10000
15
+
16
+ FRAME_FIELDS = ["first_frame_image_url", "last_frame_image_url"]
17
+ REFERENCE_FIELDS = ["reference_image_urls", "reference_video_urls", "reference_audio_urls"]
18
+
19
+
20
+ class Video(BaseModel):
21
+ url = optional(str)
22
+
23
+
24
+ class AsyncTaskResponse(TaskResponse):
25
+ """Seedance async task status response."""
26
+
27
+ id = required(str)
28
+ status = optional(str, enum=lambda: TaskResponse.Status.ALL)
29
+
30
+
31
+ class TextToVideoResponse(AsyncTaskResponse):
32
+ """Seedance video generation task status response."""
33
+
34
+ videos = optional([lambda: Video])
35
+ last_frame_image_url = optional(str)
36
+ error = optional(str)
37
+
38
+
39
+ class CompletedTextToVideoResponse(TextToVideoResponse):
40
+ """Narrowed response from ``run()`` once polling observes completion."""
41
+
42
+ videos = required([lambda: Video])
@@ -0,0 +1,272 @@
1
+ import pytest
2
+
3
+ from runapi.core import config
4
+ from runapi.core.errors import AuthenticationError, ValidationError
5
+ from runapi.seedance import SeedanceClient
6
+ from runapi.seedance.resources.text_to_video import TextToVideo
7
+ from runapi.seedance.types import CompletedTextToVideoResponse, TextToVideoResponse
8
+
9
+
10
+ class FakeHttp:
11
+ def __init__(self, *responses):
12
+ self._responses = list(responses)
13
+ self.calls = []
14
+
15
+ def request(self, method, path, body=None, options=None):
16
+ self.calls.append((method, path, body))
17
+ if self._responses:
18
+ return self._responses.pop(0)
19
+ return {"id": "task_1", "status": "pending"}
20
+
21
+
22
+ @pytest.fixture(autouse=True)
23
+ def reset_config(monkeypatch):
24
+ monkeypatch.delenv("RUNAPI_API_KEY", raising=False)
25
+ monkeypatch.setattr(config, "api_key", None)
26
+ yield
27
+
28
+
29
+ # --- auth -----------------------------------------------------------------
30
+
31
+
32
+ def test_accepts_api_key_parameter():
33
+ assert isinstance(SeedanceClient(api_key="k", http_client=FakeHttp()), SeedanceClient)
34
+
35
+
36
+ def test_falls_back_to_global(monkeypatch):
37
+ monkeypatch.setattr(config, "api_key", "global-key")
38
+ assert isinstance(SeedanceClient(http_client=FakeHttp()), SeedanceClient)
39
+
40
+
41
+ def test_falls_back_to_env(monkeypatch):
42
+ monkeypatch.setenv("RUNAPI_API_KEY", "env-key")
43
+ assert isinstance(SeedanceClient(http_client=FakeHttp()), SeedanceClient)
44
+
45
+
46
+ def test_raises_without_api_key():
47
+ with pytest.raises(AuthenticationError, match="API key is required"):
48
+ SeedanceClient()
49
+
50
+
51
+ # --- injection / accessors ------------------------------------------------
52
+
53
+
54
+ def test_uses_injected_http_client():
55
+ fake = FakeHttp()
56
+ client = SeedanceClient(api_key="k", http_client=fake)
57
+ assert client.text_to_video._http is fake
58
+
59
+
60
+ def test_exposes_resource_accessors():
61
+ client = SeedanceClient(api_key="k", http_client=FakeHttp())
62
+ assert isinstance(client.text_to_video, TextToVideo)
63
+
64
+
65
+ # --- request shapes -------------------------------------------------------
66
+
67
+
68
+ def test_create_posts_compacted_body():
69
+ fake = FakeHttp({"id": "t1", "status": "pending"})
70
+ client = SeedanceClient(api_key="k", http_client=fake)
71
+ result = client.text_to_video.create(
72
+ model="seedance-2.0", prompt="a serene lake at dawn", duration_seconds=8, seed=None
73
+ )
74
+ assert fake.calls == [
75
+ (
76
+ "post",
77
+ "/api/v1/seedance/text_to_video",
78
+ {"model": "seedance-2.0", "prompt": "a serene lake at dawn", "duration_seconds": 8},
79
+ ),
80
+ ]
81
+ assert isinstance(result, TextToVideoResponse)
82
+
83
+
84
+ def test_get_fetches_by_id():
85
+ fake = FakeHttp({"id": "t1", "status": "processing"})
86
+ client = SeedanceClient(api_key="k", http_client=fake)
87
+ client.text_to_video.get("t1")
88
+ assert fake.calls == [("get", "/api/v1/seedance/text_to_video/t1", None)]
89
+
90
+
91
+ def test_run_narrows_completed_type():
92
+ fake = FakeHttp(
93
+ {"id": "t1", "status": "pending"},
94
+ {"id": "t1", "status": "completed", "videos": [{"url": "https://x/y.mp4"}]},
95
+ )
96
+ client = SeedanceClient(api_key="k", http_client=fake)
97
+ result = client.text_to_video.run(
98
+ model="seedance-2.0", prompt="a cinematic city flyover", duration_seconds=8
99
+ )
100
+ assert isinstance(result, CompletedTextToVideoResponse)
101
+ assert result.videos[0].url == "https://x/y.mp4"
102
+
103
+
104
+ # --- validation -----------------------------------------------------------
105
+
106
+
107
+ def test_requires_model():
108
+ client = SeedanceClient(api_key="k", http_client=FakeHttp())
109
+ with pytest.raises(ValidationError, match="model must be one of:"):
110
+ client.text_to_video.create(prompt="a serene lake at dawn")
111
+
112
+
113
+ def test_rejects_unknown_model():
114
+ client = SeedanceClient(api_key="k", http_client=FakeHttp())
115
+ with pytest.raises(ValidationError, match="model must be one of:"):
116
+ client.text_to_video.create(model="nope", prompt="a serene lake at dawn")
117
+
118
+
119
+ def test_requires_prompt():
120
+ client = SeedanceClient(api_key="k", http_client=FakeHttp())
121
+ with pytest.raises(ValidationError, match="prompt is required"):
122
+ client.text_to_video.create(model="seedance-2.0")
123
+
124
+
125
+ def test_prompt_length_bounds():
126
+ client = SeedanceClient(api_key="k", http_client=FakeHttp())
127
+ with pytest.raises(
128
+ ValidationError, match="prompt length must be between 3 and 20000 characters"
129
+ ):
130
+ client.text_to_video.create(model="seedance-2.0", prompt="hi", duration_seconds=8)
131
+
132
+
133
+ # --- conditional validators per model version -----------------------------
134
+
135
+
136
+ def test_v2_aspect_ratio_enum():
137
+ client = SeedanceClient(api_key="k", http_client=FakeHttp())
138
+ with pytest.raises(ValidationError, match="aspect_ratio must be one of:"):
139
+ client.text_to_video.create(
140
+ model="seedance-2.0", prompt="a serene lake at dawn", aspect_ratio="bogus"
141
+ )
142
+
143
+
144
+ def test_v2_fast_resolution_excludes_1080p():
145
+ client = SeedanceClient(api_key="k", http_client=FakeHttp())
146
+ with pytest.raises(ValidationError, match="output_resolution must be one of:"):
147
+ client.text_to_video.create(
148
+ model="seedance-2.0-fast", prompt="a serene lake at dawn", output_resolution="1080p"
149
+ )
150
+
151
+
152
+ def test_v2_duration_range():
153
+ client = SeedanceClient(api_key="k", http_client=FakeHttp())
154
+ with pytest.raises(
155
+ ValidationError, match="duration_seconds must be between 4 and 15"
156
+ ):
157
+ client.text_to_video.create(
158
+ model="seedance-2.0", prompt="a serene lake at dawn", duration_seconds=99
159
+ )
160
+
161
+
162
+ def test_v2_frame_and_reference_conflict():
163
+ client = SeedanceClient(api_key="k", http_client=FakeHttp())
164
+ with pytest.raises(
165
+ ValidationError, match="Cannot use frame mode and reference mode at the same time"
166
+ ):
167
+ client.text_to_video.create(
168
+ model="seedance-2.0",
169
+ prompt="a serene lake at dawn",
170
+ first_frame_image_url="https://x/a.png",
171
+ reference_image_urls=["https://x/b.png"],
172
+ )
173
+
174
+
175
+ def test_v2_rejects_source_image_urls():
176
+ client = SeedanceClient(api_key="k", http_client=FakeHttp())
177
+ with pytest.raises(ValidationError, match="source_image_urls is not supported"):
178
+ client.text_to_video.create(
179
+ model="seedance-2.0",
180
+ prompt="a serene lake at dawn",
181
+ source_image_urls=["https://x/a.png"],
182
+ )
183
+
184
+
185
+ def test_1_5_pro_requires_duration():
186
+ client = SeedanceClient(api_key="k", http_client=FakeHttp())
187
+ with pytest.raises(
188
+ ValidationError, match="duration_seconds is required"
189
+ ):
190
+ client.text_to_video.create(model="seedance-1.5-pro", prompt="a serene lake at dawn")
191
+
192
+
193
+ def test_1_5_pro_invalid_duration():
194
+ client = SeedanceClient(api_key="k", http_client=FakeHttp())
195
+ with pytest.raises(
196
+ ValidationError, match="duration_seconds must be between 4 and 12"
197
+ ):
198
+ client.text_to_video.create(
199
+ model="seedance-1.5-pro", prompt="a serene lake at dawn", duration_seconds=13
200
+ )
201
+
202
+
203
+ def test_1_5_pro_source_image_cap():
204
+ client = SeedanceClient(api_key="k", http_client=FakeHttp())
205
+ with pytest.raises(
206
+ ValidationError, match="source_image_urls accepts at most 2 images"
207
+ ):
208
+ client.text_to_video.create(
209
+ model="seedance-1.5-pro",
210
+ prompt="a serene lake at dawn",
211
+ duration_seconds=4,
212
+ source_image_urls=["a", "b", "c"],
213
+ )
214
+
215
+
216
+ def test_v1_requires_duration():
217
+ client = SeedanceClient(api_key="k", http_client=FakeHttp())
218
+ with pytest.raises(
219
+ ValidationError, match="duration_seconds is required"
220
+ ):
221
+ client.text_to_video.create(model="seedance-v1-lite", prompt="a serene lake at dawn")
222
+
223
+
224
+ def test_v1_pro_fast_requires_first_frame():
225
+ client = SeedanceClient(api_key="k", http_client=FakeHttp())
226
+ with pytest.raises(
227
+ ValidationError, match="first_frame_image_url is required"
228
+ ):
229
+ client.text_to_video.create(
230
+ model="seedance-v1-pro-fast", prompt="a serene lake at dawn", duration_seconds=5
231
+ )
232
+
233
+
234
+ def test_v1_image_mode_rejects_aspect_ratio():
235
+ client = SeedanceClient(api_key="k", http_client=FakeHttp())
236
+ with pytest.raises(
237
+ ValidationError, match="aspect_ratio is not accepted in image-to-video mode"
238
+ ):
239
+ client.text_to_video.create(
240
+ model="seedance-v1-lite",
241
+ prompt="a serene lake at dawn",
242
+ duration_seconds=5,
243
+ first_frame_image_url="https://x/a.png",
244
+ aspect_ratio="1:1",
245
+ )
246
+
247
+
248
+ def test_v1_seed_range():
249
+ client = SeedanceClient(api_key="k", http_client=FakeHttp())
250
+ with pytest.raises(
251
+ ValidationError, match="seed must be an integer between -1 and 2147483647"
252
+ ):
253
+ client.text_to_video.create(
254
+ model="seedance-v1-lite",
255
+ prompt="a serene lake at dawn",
256
+ duration_seconds=5,
257
+ seed=-5,
258
+ )
259
+
260
+
261
+ def test_non_numeric_duration_raises_validation_error():
262
+ # Regression: a non-numeric duration must raise the SDK's ValidationError,
263
+ # not a bare ValueError from int(). duration_seconds is type: integer, so the
264
+ # contract validator rejects it as a non-integer (mirroring the gateway)
265
+ # before any int() coercion runs.
266
+ client = SeedanceClient(api_key="k", http_client=FakeHttp())
267
+ with pytest.raises(
268
+ ValidationError, match="duration_seconds must be an integer between 4 and 15"
269
+ ):
270
+ client.text_to_video.create(
271
+ model="seedance-2.0", prompt="a serene lake at dawn", duration_seconds="abc"
272
+ )