runapi-runway 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_runway-0.1.0/.gitignore +29 -0
- runapi_runway-0.1.0/PKG-INFO +90 -0
- runapi_runway-0.1.0/README.md +77 -0
- runapi_runway-0.1.0/pyproject.toml +33 -0
- runapi_runway-0.1.0/src/runapi/runway/__init__.py +24 -0
- runapi_runway-0.1.0/src/runapi/runway/client.py +32 -0
- runapi_runway-0.1.0/src/runapi/runway/contract_gen.py +41 -0
- runapi_runway-0.1.0/src/runapi/runway/py.typed +0 -0
- runapi_runway-0.1.0/src/runapi/runway/resources/__init__.py +4 -0
- runapi_runway-0.1.0/src/runapi/runway/resources/extend_video.py +60 -0
- runapi_runway-0.1.0/src/runapi/runway/resources/text_to_video.py +60 -0
- runapi_runway-0.1.0/src/runapi/runway/types.py +35 -0
- runapi_runway-0.1.0/tests/test_client.py +210 -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,90 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: runapi-runway
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Runway text-to-video and extend-video client for RunAPI
|
|
5
|
+
Project-URL: Homepage, https://runapi.ai/models/runway
|
|
6
|
+
Project-URL: Documentation, https://runapi.ai/docs#sdk-runway
|
|
7
|
+
Author-email: RunAPI <contact@runapi.ai>
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
Keywords: ai,extend-video,runapi,runway,sdk,text-to-video
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Requires-Dist: runapi-core
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# Runway Python SDK for RunAPI
|
|
15
|
+
|
|
16
|
+
The Runway Python SDK is the language-specific package for Runway on RunAPI.
|
|
17
|
+
Use it for text-to-video and extend-video flows when your application needs JSON
|
|
18
|
+
request bodies, task status lookup, and consistent RunAPI errors in Python.
|
|
19
|
+
|
|
20
|
+
For model details, use https://runapi.ai/models/runway; for API reference, use
|
|
21
|
+
https://runapi.ai/docs#runway; for SDK docs, use https://runapi.ai/docs#sdk-runway.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install runapi-runway
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Quick start
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from runapi.runway import RunwayClient
|
|
33
|
+
|
|
34
|
+
client = RunwayClient() # reads RUNAPI_API_KEY, or pass api_key="sk-..."
|
|
35
|
+
|
|
36
|
+
task = client.text_to_video.create(
|
|
37
|
+
model="...",
|
|
38
|
+
prompt="A drone shot over a coastal city at sunset, cinematic",
|
|
39
|
+
duration_seconds=5,
|
|
40
|
+
output_resolution="720p",
|
|
41
|
+
aspect_ratio="16:9",
|
|
42
|
+
)
|
|
43
|
+
status = client.text_to_video.get(task.id)
|
|
44
|
+
|
|
45
|
+
extended = client.extend_video.create(
|
|
46
|
+
model="...",
|
|
47
|
+
source_task_id=task.id,
|
|
48
|
+
prompt="Continue the camera push toward the harbor",
|
|
49
|
+
output_resolution="720p",
|
|
50
|
+
)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Use `create` to submit a task and return quickly, `get` to fetch the latest task
|
|
54
|
+
state, and `run` to create and poll until completion:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
result = client.text_to_video.run(
|
|
58
|
+
model="...",
|
|
59
|
+
prompt="A serene mountain lake at dawn, slow pan",
|
|
60
|
+
duration_seconds=5,
|
|
61
|
+
output_resolution="720p",
|
|
62
|
+
)
|
|
63
|
+
print(result.videos[0].url)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
In web request handlers, prefer `create` plus webhook or later `get` polling so a
|
|
67
|
+
worker is not held open.
|
|
68
|
+
|
|
69
|
+
RunAPI-generated file URLs are temporary. Download and store generated videos in
|
|
70
|
+
your own durable storage within 7 days; do not treat returned URLs as long-term
|
|
71
|
+
assets.
|
|
72
|
+
|
|
73
|
+
## Language notes
|
|
74
|
+
|
|
75
|
+
Pass parameters as keyword arguments and catch the `runapi.runway` error classes
|
|
76
|
+
when building video jobs or scripts. The available resources are `text_to_video`
|
|
77
|
+
and `extend_video`. Keep `RUNAPI_API_KEY` in the environment or your secret
|
|
78
|
+
manager; never commit API keys or callback secrets.
|
|
79
|
+
|
|
80
|
+
## Links
|
|
81
|
+
|
|
82
|
+
- Model page: https://runapi.ai/models/runway
|
|
83
|
+
- SDK docs: https://runapi.ai/docs#sdk-runway
|
|
84
|
+
- Product docs: https://runapi.ai/docs#runway
|
|
85
|
+
- Pricing and rate limits: https://runapi.ai/models/runway
|
|
86
|
+
- Full catalog: https://runapi.ai/models
|
|
87
|
+
|
|
88
|
+
## License
|
|
89
|
+
|
|
90
|
+
Licensed under the Apache License, Version 2.0.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Runway Python SDK for RunAPI
|
|
2
|
+
|
|
3
|
+
The Runway Python SDK is the language-specific package for Runway on RunAPI.
|
|
4
|
+
Use it for text-to-video and extend-video flows when your application needs JSON
|
|
5
|
+
request bodies, task status lookup, and consistent RunAPI errors in Python.
|
|
6
|
+
|
|
7
|
+
For model details, use https://runapi.ai/models/runway; for API reference, use
|
|
8
|
+
https://runapi.ai/docs#runway; for SDK docs, use https://runapi.ai/docs#sdk-runway.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install runapi-runway
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Quick start
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
from runapi.runway import RunwayClient
|
|
20
|
+
|
|
21
|
+
client = RunwayClient() # reads RUNAPI_API_KEY, or pass api_key="sk-..."
|
|
22
|
+
|
|
23
|
+
task = client.text_to_video.create(
|
|
24
|
+
model="...",
|
|
25
|
+
prompt="A drone shot over a coastal city at sunset, cinematic",
|
|
26
|
+
duration_seconds=5,
|
|
27
|
+
output_resolution="720p",
|
|
28
|
+
aspect_ratio="16:9",
|
|
29
|
+
)
|
|
30
|
+
status = client.text_to_video.get(task.id)
|
|
31
|
+
|
|
32
|
+
extended = client.extend_video.create(
|
|
33
|
+
model="...",
|
|
34
|
+
source_task_id=task.id,
|
|
35
|
+
prompt="Continue the camera push toward the harbor",
|
|
36
|
+
output_resolution="720p",
|
|
37
|
+
)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Use `create` to submit a task and return quickly, `get` to fetch the latest task
|
|
41
|
+
state, and `run` to create and poll until completion:
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
result = client.text_to_video.run(
|
|
45
|
+
model="...",
|
|
46
|
+
prompt="A serene mountain lake at dawn, slow pan",
|
|
47
|
+
duration_seconds=5,
|
|
48
|
+
output_resolution="720p",
|
|
49
|
+
)
|
|
50
|
+
print(result.videos[0].url)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
In web request handlers, prefer `create` plus webhook or later `get` polling so a
|
|
54
|
+
worker is not held open.
|
|
55
|
+
|
|
56
|
+
RunAPI-generated file URLs are temporary. Download and store generated videos in
|
|
57
|
+
your own durable storage within 7 days; do not treat returned URLs as long-term
|
|
58
|
+
assets.
|
|
59
|
+
|
|
60
|
+
## Language notes
|
|
61
|
+
|
|
62
|
+
Pass parameters as keyword arguments and catch the `runapi.runway` error classes
|
|
63
|
+
when building video jobs or scripts. The available resources are `text_to_video`
|
|
64
|
+
and `extend_video`. Keep `RUNAPI_API_KEY` in the environment or your secret
|
|
65
|
+
manager; never commit API keys or callback secrets.
|
|
66
|
+
|
|
67
|
+
## Links
|
|
68
|
+
|
|
69
|
+
- Model page: https://runapi.ai/models/runway
|
|
70
|
+
- SDK docs: https://runapi.ai/docs#sdk-runway
|
|
71
|
+
- Product docs: https://runapi.ai/docs#runway
|
|
72
|
+
- Pricing and rate limits: https://runapi.ai/models/runway
|
|
73
|
+
- Full catalog: https://runapi.ai/models
|
|
74
|
+
|
|
75
|
+
## License
|
|
76
|
+
|
|
77
|
+
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-runway"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Runway text-to-video and extend-video 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", "runway", "text-to-video", "extend-video", "ai", "sdk"]
|
|
14
|
+
dependencies = ["runapi-core"]
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Homepage = "https://runapi.ai/models/runway"
|
|
18
|
+
Documentation = "https://runapi.ai/docs#sdk-runway"
|
|
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 = "runway"
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Runway 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 RunwayClient
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"RunwayClient",
|
|
17
|
+
"AuthenticationError",
|
|
18
|
+
"RateLimitError",
|
|
19
|
+
"InsufficientCreditsError",
|
|
20
|
+
"NotFoundError",
|
|
21
|
+
"ValidationError",
|
|
22
|
+
"TaskFailedError",
|
|
23
|
+
"TaskTimeoutError",
|
|
24
|
+
]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Runway 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.extend_video import ExtendVideo
|
|
10
|
+
from .resources.text_to_video import TextToVideo
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RunwayClient:
|
|
14
|
+
"""Runway text-to-video and extend-video client.
|
|
15
|
+
|
|
16
|
+
Example::
|
|
17
|
+
|
|
18
|
+
client = RunwayClient(api_key="sk-...")
|
|
19
|
+
result = client.text_to_video.run(
|
|
20
|
+
model="...",
|
|
21
|
+
prompt="A drone shot over a coastal city at sunset",
|
|
22
|
+
duration_seconds=5,
|
|
23
|
+
output_resolution="720p",
|
|
24
|
+
)
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, api_key: Optional[str] = None, **options: Any) -> None:
|
|
28
|
+
resolved_api_key = resolve_api_key(api_key)
|
|
29
|
+
client_options = ClientOptions(api_key=resolved_api_key, **options)
|
|
30
|
+
http = client_options.http_client or HttpClient(client_options)
|
|
31
|
+
self.text_to_video = TextToVideo(http)
|
|
32
|
+
self.extend_video = ExtendVideo(http)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
CONTRACT = {
|
|
2
|
+
"extend-video": {
|
|
3
|
+
"models": ["runway"],
|
|
4
|
+
"fields_by_model": {
|
|
5
|
+
"runway": {
|
|
6
|
+
"output_resolution": {
|
|
7
|
+
"enum": ["720p", "1080p"],
|
|
8
|
+
"required": True
|
|
9
|
+
},
|
|
10
|
+
"prompt": {
|
|
11
|
+
"required": True
|
|
12
|
+
},
|
|
13
|
+
"source_task_id": {
|
|
14
|
+
"required": True
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"text-to-video": {
|
|
20
|
+
"models": ["runway"],
|
|
21
|
+
"fields_by_model": {
|
|
22
|
+
"runway": {
|
|
23
|
+
"aspect_ratio": {
|
|
24
|
+
"enum": ["16:9", "9:16", "1:1", "4:3", "3:4"]
|
|
25
|
+
},
|
|
26
|
+
"duration_seconds": {
|
|
27
|
+
"enum": [5, 10],
|
|
28
|
+
"required": True,
|
|
29
|
+
"type": "integer"
|
|
30
|
+
},
|
|
31
|
+
"output_resolution": {
|
|
32
|
+
"enum": ["720p", "1080p"],
|
|
33
|
+
"required": True
|
|
34
|
+
},
|
|
35
|
+
"prompt": {
|
|
36
|
+
"required": True
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Runway extend-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
|
+
CompletedTaskResponse,
|
|
12
|
+
TaskCreateResponse,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ExtendVideo(Resource):
|
|
17
|
+
"""Extend an existing Runway video task."""
|
|
18
|
+
|
|
19
|
+
ENDPOINT = "/api/v1/runway/extend_video"
|
|
20
|
+
|
|
21
|
+
RESPONSE_CLASS = TaskCreateResponse
|
|
22
|
+
COMPLETED_RESPONSE_CLASS = CompletedTaskResponse
|
|
23
|
+
|
|
24
|
+
MODEL = "runway"
|
|
25
|
+
|
|
26
|
+
def run(self, **params: Any) -> Any:
|
|
27
|
+
"""Append footage to a previous video and poll until it completes.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
**params: extend-video parameters (model, ...).
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
The completed (narrowed) extend-video response.
|
|
34
|
+
"""
|
|
35
|
+
task = self.create(**params)
|
|
36
|
+
return self._poll_until_complete(lambda: self.get(task.id))
|
|
37
|
+
|
|
38
|
+
def create(self, **params: Any) -> Any:
|
|
39
|
+
"""Create an extend-video task and return immediately with an id.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
**params: extend-video parameters (model, ...).
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
The task creation result with an id.
|
|
46
|
+
"""
|
|
47
|
+
compacted = self._compact_params(params)
|
|
48
|
+
self._validate_contract(CONTRACT["extend-video"], {**compacted, "model": self.MODEL})
|
|
49
|
+
return self._request("post", self.ENDPOINT, body=compacted)
|
|
50
|
+
|
|
51
|
+
def get(self, id: str) -> Any:
|
|
52
|
+
"""Fetch the current status of an extend-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,60 @@
|
|
|
1
|
+
"""Runway text-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
|
+
CompletedTaskResponse,
|
|
12
|
+
TaskCreateResponse,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TextToVideo(Resource):
|
|
17
|
+
"""Generate videos from text prompts with Runway models."""
|
|
18
|
+
|
|
19
|
+
ENDPOINT = "/api/v1/runway/text_to_video"
|
|
20
|
+
|
|
21
|
+
RESPONSE_CLASS = TaskCreateResponse
|
|
22
|
+
COMPLETED_RESPONSE_CLASS = CompletedTaskResponse
|
|
23
|
+
|
|
24
|
+
MODEL = "runway"
|
|
25
|
+
|
|
26
|
+
def run(self, **params: Any) -> Any:
|
|
27
|
+
"""Create a text-to-video task and poll until it completes.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
**params: text-to-video parameters (model, ...).
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
The completed (narrowed) text-to-video response.
|
|
34
|
+
"""
|
|
35
|
+
task = self.create(**params)
|
|
36
|
+
return self._poll_until_complete(lambda: self.get(task.id))
|
|
37
|
+
|
|
38
|
+
def create(self, **params: Any) -> Any:
|
|
39
|
+
"""Create a text-to-video task and return immediately with an id.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
**params: text-to-video parameters (model, ...).
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
The task creation result with an id.
|
|
46
|
+
"""
|
|
47
|
+
compacted = self._compact_params(params)
|
|
48
|
+
self._validate_contract(CONTRACT["text-to-video"], {**compacted, "model": self.MODEL})
|
|
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,35 @@
|
|
|
1
|
+
"""Runway 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
|
+
id = optional(str)
|
|
10
|
+
url = required(str)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Image(BaseModel):
|
|
14
|
+
url = required(str)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TaskResponseModel(TaskResponse):
|
|
18
|
+
"""Runway task status response."""
|
|
19
|
+
|
|
20
|
+
id = required(str)
|
|
21
|
+
status = optional(str, enum=lambda: TaskResponse.Status.ALL)
|
|
22
|
+
videos = optional([lambda: Video])
|
|
23
|
+
images = optional([lambda: Image])
|
|
24
|
+
source_task_id = optional(str)
|
|
25
|
+
error = optional(str)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TaskCreateResponse(TaskResponseModel):
|
|
29
|
+
"""Runway task creation response with an id."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CompletedTaskResponse(TaskResponseModel):
|
|
33
|
+
"""Narrowed response from ``run()`` once polling observes completion."""
|
|
34
|
+
|
|
35
|
+
videos = required([lambda: Video])
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from runapi.core import config
|
|
4
|
+
from runapi.core.errors import AuthenticationError, ValidationError
|
|
5
|
+
from runapi.runway import RunwayClient
|
|
6
|
+
from runapi.runway.resources.extend_video import ExtendVideo
|
|
7
|
+
from runapi.runway.resources.text_to_video import TextToVideo
|
|
8
|
+
from runapi.runway.types import CompletedTaskResponse, TaskCreateResponse
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FakeHttp:
|
|
12
|
+
def __init__(self, *responses):
|
|
13
|
+
self._responses = list(responses)
|
|
14
|
+
self.calls = []
|
|
15
|
+
|
|
16
|
+
def request(self, method, path, body=None, options=None):
|
|
17
|
+
self.calls.append((method, path, body))
|
|
18
|
+
if self._responses:
|
|
19
|
+
return self._responses.pop(0)
|
|
20
|
+
return {"id": "task_1", "status": "pending"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.fixture(autouse=True)
|
|
24
|
+
def reset_config(monkeypatch):
|
|
25
|
+
monkeypatch.delenv("RUNAPI_API_KEY", raising=False)
|
|
26
|
+
monkeypatch.setattr(config, "api_key", None)
|
|
27
|
+
yield
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# --- auth -----------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_accepts_api_key_parameter():
|
|
34
|
+
assert isinstance(RunwayClient(api_key="k", http_client=FakeHttp()), RunwayClient)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_falls_back_to_global(monkeypatch):
|
|
38
|
+
monkeypatch.setattr(config, "api_key", "global-key")
|
|
39
|
+
assert isinstance(RunwayClient(http_client=FakeHttp()), RunwayClient)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_falls_back_to_env(monkeypatch):
|
|
43
|
+
monkeypatch.setenv("RUNAPI_API_KEY", "env-key")
|
|
44
|
+
assert isinstance(RunwayClient(http_client=FakeHttp()), RunwayClient)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_raises_without_api_key():
|
|
48
|
+
with pytest.raises(AuthenticationError, match="API key is required"):
|
|
49
|
+
RunwayClient()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# --- injection / accessors ------------------------------------------------
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_uses_injected_http_client():
|
|
56
|
+
fake = FakeHttp()
|
|
57
|
+
client = RunwayClient(api_key="k", http_client=fake)
|
|
58
|
+
assert client.text_to_video._http is fake
|
|
59
|
+
assert client.extend_video._http is fake
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_exposes_resource_accessors():
|
|
63
|
+
client = RunwayClient(api_key="k", http_client=FakeHttp())
|
|
64
|
+
assert isinstance(client.text_to_video, TextToVideo)
|
|
65
|
+
assert isinstance(client.extend_video, ExtendVideo)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# --- request shapes -------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_create_posts_compacted_body():
|
|
72
|
+
fake = FakeHttp({"id": "t1", "status": "pending"})
|
|
73
|
+
client = RunwayClient(api_key="k", http_client=fake)
|
|
74
|
+
result = client.text_to_video.create(
|
|
75
|
+
prompt="hello world",
|
|
76
|
+
duration_seconds=5,
|
|
77
|
+
output_resolution="720p",
|
|
78
|
+
aspect_ratio="16:9",
|
|
79
|
+
seed=None,
|
|
80
|
+
)
|
|
81
|
+
assert fake.calls == [
|
|
82
|
+
(
|
|
83
|
+
"post",
|
|
84
|
+
"/api/v1/runway/text_to_video",
|
|
85
|
+
{
|
|
86
|
+
"prompt": "hello world",
|
|
87
|
+
"duration_seconds": 5,
|
|
88
|
+
"output_resolution": "720p",
|
|
89
|
+
"aspect_ratio": "16:9",
|
|
90
|
+
},
|
|
91
|
+
),
|
|
92
|
+
]
|
|
93
|
+
_, _, body = fake.calls[0]
|
|
94
|
+
assert "model" not in body
|
|
95
|
+
assert isinstance(result, TaskCreateResponse)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_get_fetches_by_id():
|
|
99
|
+
fake = FakeHttp({"id": "t1", "status": "processing"})
|
|
100
|
+
client = RunwayClient(api_key="k", http_client=fake)
|
|
101
|
+
client.text_to_video.get("t1")
|
|
102
|
+
assert fake.calls == [("get", "/api/v1/runway/text_to_video/t1", None)]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_extend_video_create_posts_compacted_body():
|
|
106
|
+
fake = FakeHttp({"id": "t2", "status": "pending"})
|
|
107
|
+
client = RunwayClient(api_key="k", http_client=fake)
|
|
108
|
+
client.extend_video.create(
|
|
109
|
+
source_task_id="t1",
|
|
110
|
+
prompt="keep going",
|
|
111
|
+
output_resolution="1080p",
|
|
112
|
+
)
|
|
113
|
+
assert fake.calls == [
|
|
114
|
+
(
|
|
115
|
+
"post",
|
|
116
|
+
"/api/v1/runway/extend_video",
|
|
117
|
+
{
|
|
118
|
+
"source_task_id": "t1",
|
|
119
|
+
"prompt": "keep going",
|
|
120
|
+
"output_resolution": "1080p",
|
|
121
|
+
},
|
|
122
|
+
),
|
|
123
|
+
]
|
|
124
|
+
_, _, body = fake.calls[0]
|
|
125
|
+
assert "model" not in body
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_run_narrows_completed_type():
|
|
129
|
+
fake = FakeHttp(
|
|
130
|
+
{"id": "t1", "status": "pending"},
|
|
131
|
+
{"id": "t1", "status": "completed", "videos": [{"url": "https://x/y.mp4"}]},
|
|
132
|
+
)
|
|
133
|
+
client = RunwayClient(api_key="k", http_client=fake)
|
|
134
|
+
result = client.text_to_video.run(
|
|
135
|
+
model="runway",
|
|
136
|
+
prompt="a serene lake",
|
|
137
|
+
duration_seconds=5,
|
|
138
|
+
output_resolution="720p",
|
|
139
|
+
)
|
|
140
|
+
assert isinstance(result, CompletedTaskResponse)
|
|
141
|
+
assert result.videos[0].url == "https://x/y.mp4"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# --- validation -----------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_text_to_video_requires_prompt():
|
|
148
|
+
client = RunwayClient(api_key="k", http_client=FakeHttp())
|
|
149
|
+
with pytest.raises(ValidationError, match="prompt is required"):
|
|
150
|
+
client.text_to_video.create(
|
|
151
|
+
model="runway", duration_seconds=5, output_resolution="720p"
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_text_to_video_requires_duration_seconds():
|
|
156
|
+
client = RunwayClient(api_key="k", http_client=FakeHttp())
|
|
157
|
+
with pytest.raises(ValidationError, match="duration_seconds is required"):
|
|
158
|
+
client.text_to_video.create(
|
|
159
|
+
model="runway", prompt="hi there", output_resolution="720p"
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_text_to_video_requires_output_resolution():
|
|
164
|
+
client = RunwayClient(api_key="k", http_client=FakeHttp())
|
|
165
|
+
with pytest.raises(ValidationError, match="output_resolution is required"):
|
|
166
|
+
client.text_to_video.create(model="runway", prompt="hi there", duration_seconds=5)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_text_to_video_rejects_invalid_output_resolution():
|
|
170
|
+
client = RunwayClient(api_key="k", http_client=FakeHttp())
|
|
171
|
+
with pytest.raises(ValidationError, match="output_resolution must be one of: 720p, 1080p"):
|
|
172
|
+
client.text_to_video.create(
|
|
173
|
+
model="runway", prompt="hi there", duration_seconds=5, output_resolution="480p"
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_text_to_video_rejects_invalid_aspect_ratio():
|
|
178
|
+
client = RunwayClient(api_key="k", http_client=FakeHttp())
|
|
179
|
+
with pytest.raises(
|
|
180
|
+
ValidationError, match="aspect_ratio must be one of: 16:9, 9:16, 1:1, 4:3, 3:4"
|
|
181
|
+
):
|
|
182
|
+
client.text_to_video.create(
|
|
183
|
+
model="runway",
|
|
184
|
+
prompt="hi there",
|
|
185
|
+
duration_seconds=5,
|
|
186
|
+
output_resolution="720p",
|
|
187
|
+
aspect_ratio="21:9",
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_extend_video_requires_source_task_id():
|
|
192
|
+
client = RunwayClient(api_key="k", http_client=FakeHttp())
|
|
193
|
+
with pytest.raises(ValidationError, match="source_task_id is required"):
|
|
194
|
+
client.extend_video.create(
|
|
195
|
+
model="runway", prompt="hi there", output_resolution="720p"
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_extend_video_requires_prompt():
|
|
200
|
+
client = RunwayClient(api_key="k", http_client=FakeHttp())
|
|
201
|
+
with pytest.raises(ValidationError, match="prompt is required"):
|
|
202
|
+
client.extend_video.create(
|
|
203
|
+
model="runway", source_task_id="t1", output_resolution="720p"
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def test_extend_video_requires_output_resolution():
|
|
208
|
+
client = RunwayClient(api_key="k", http_client=FakeHttp())
|
|
209
|
+
with pytest.raises(ValidationError, match="output_resolution is required"):
|
|
210
|
+
client.extend_video.create(model="runway", source_task_id="t1", prompt="hi there")
|