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.
- runapi_seedance-0.1.0/.gitignore +29 -0
- runapi_seedance-0.1.0/PKG-INFO +81 -0
- runapi_seedance-0.1.0/README.md +68 -0
- runapi_seedance-0.1.0/pyproject.toml +33 -0
- runapi_seedance-0.1.0/src/runapi/seedance/__init__.py +24 -0
- runapi_seedance-0.1.0/src/runapi/seedance/client.py +27 -0
- runapi_seedance-0.1.0/src/runapi/seedance/contract_gen.py +104 -0
- runapi_seedance-0.1.0/src/runapi/seedance/py.typed +0 -0
- runapi_seedance-0.1.0/src/runapi/seedance/resources/__init__.py +3 -0
- runapi_seedance-0.1.0/src/runapi/seedance/resources/text_to_video.py +170 -0
- runapi_seedance-0.1.0/src/runapi/seedance/types.py +42 -0
- runapi_seedance-0.1.0/tests/test_client.py +272 -0
|
@@ -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,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
|
+
)
|