runapi-happyhorse 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_happyhorse-0.1.0/.gitignore +29 -0
- runapi_happyhorse-0.1.0/PKG-INFO +87 -0
- runapi_happyhorse-0.1.0/README.md +74 -0
- runapi_happyhorse-0.1.0/pyproject.toml +30 -0
- runapi_happyhorse-0.1.0/src/runapi/happyhorse/__init__.py +24 -0
- runapi_happyhorse-0.1.0/src/runapi/happyhorse/client.py +31 -0
- runapi_happyhorse-0.1.0/src/runapi/happyhorse/py.typed +0 -0
- runapi_happyhorse-0.1.0/src/runapi/happyhorse/resources/__init__.py +5 -0
- runapi_happyhorse-0.1.0/src/runapi/happyhorse/resources/edit_video.py +94 -0
- runapi_happyhorse-0.1.0/src/runapi/happyhorse/resources/image_to_video.py +82 -0
- runapi_happyhorse-0.1.0/src/runapi/happyhorse/resources/text_to_video.py +100 -0
- runapi_happyhorse-0.1.0/src/runapi/happyhorse/types.py +41 -0
- runapi_happyhorse-0.1.0/tests/test_client.py +235 -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,87 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: runapi-happyhorse
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: HappyHorse text-to-video, image-to-video, and edit-video client for RunAPI
|
|
5
|
+
Project-URL: Homepage, https://runapi.ai/models/happyhorse
|
|
6
|
+
Project-URL: Documentation, https://runapi.ai/docs#sdk-happyhorse
|
|
7
|
+
Author-email: RunAPI <contact@runapi.ai>
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
Keywords: ai,edit-video,happyhorse,image-to-video,runapi,sdk,text-to-video
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Requires-Dist: runapi-core
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# HappyHorse Python SDK for RunAPI
|
|
15
|
+
|
|
16
|
+
The HappyHorse Python SDK is the language-specific package for HappyHorse on
|
|
17
|
+
RunAPI. Use it for text-to-video, image-to-video, and edit-video 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/happyhorse; for API reference,
|
|
22
|
+
use https://runapi.ai/docs#happyhorse; for SDK docs, use
|
|
23
|
+
https://runapi.ai/docs#sdk-happyhorse.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install runapi-happyhorse
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Quick start
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from runapi.happyhorse import HappyHorseClient
|
|
35
|
+
|
|
36
|
+
client = HappyHorseClient() # reads RUNAPI_API_KEY, or pass api_key="sk-..."
|
|
37
|
+
|
|
38
|
+
task = client.text_to_video.create(
|
|
39
|
+
model="happyhorse-text-to-video",
|
|
40
|
+
prompt="A horse galloping along a beach at sunset, cinematic",
|
|
41
|
+
aspect_ratio="16:9",
|
|
42
|
+
output_resolution="1080p",
|
|
43
|
+
)
|
|
44
|
+
status = client.text_to_video.get(task.id)
|
|
45
|
+
|
|
46
|
+
clip = client.image_to_video.create(
|
|
47
|
+
model="happyhorse-image-to-video",
|
|
48
|
+
first_frame_image_url="https://example.com/first-frame.jpg",
|
|
49
|
+
)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Use `create` to submit a task and return quickly, `get` to fetch the latest task
|
|
53
|
+
state, and `run` to create and poll until completion:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
result = client.text_to_video.run(
|
|
57
|
+
model="happyhorse-text-to-video",
|
|
58
|
+
prompt="A serene mountain river at dawn",
|
|
59
|
+
)
|
|
60
|
+
print(result.videos[0].url)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
In web request handlers, prefer `create` plus webhook or later `get` polling so a
|
|
64
|
+
worker is not held open.
|
|
65
|
+
|
|
66
|
+
RunAPI-generated file URLs are temporary. Download and store generated videos in
|
|
67
|
+
your own durable storage within 7 days; do not treat returned URLs as long-term
|
|
68
|
+
assets.
|
|
69
|
+
|
|
70
|
+
## Language notes
|
|
71
|
+
|
|
72
|
+
Pass parameters as keyword arguments and catch the `runapi.happyhorse` error
|
|
73
|
+
classes when building video jobs or scripts. The available resources are
|
|
74
|
+
`text_to_video`, `image_to_video`, and `edit_video`. Keep `RUNAPI_API_KEY` in the
|
|
75
|
+
environment or your secret manager; never commit API keys or callback secrets.
|
|
76
|
+
|
|
77
|
+
## Links
|
|
78
|
+
|
|
79
|
+
- Model page: https://runapi.ai/models/happyhorse
|
|
80
|
+
- SDK docs: https://runapi.ai/docs#sdk-happyhorse
|
|
81
|
+
- Product docs: https://runapi.ai/docs#happyhorse
|
|
82
|
+
- Pricing and rate limits: https://runapi.ai/models/happyhorse
|
|
83
|
+
- Full catalog: https://runapi.ai/models
|
|
84
|
+
|
|
85
|
+
## License
|
|
86
|
+
|
|
87
|
+
Licensed under the Apache License, Version 2.0.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# HappyHorse Python SDK for RunAPI
|
|
2
|
+
|
|
3
|
+
The HappyHorse Python SDK is the language-specific package for HappyHorse on
|
|
4
|
+
RunAPI. Use it for text-to-video, image-to-video, and edit-video 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/happyhorse; for API reference,
|
|
9
|
+
use https://runapi.ai/docs#happyhorse; for SDK docs, use
|
|
10
|
+
https://runapi.ai/docs#sdk-happyhorse.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install runapi-happyhorse
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Quick start
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from runapi.happyhorse import HappyHorseClient
|
|
22
|
+
|
|
23
|
+
client = HappyHorseClient() # reads RUNAPI_API_KEY, or pass api_key="sk-..."
|
|
24
|
+
|
|
25
|
+
task = client.text_to_video.create(
|
|
26
|
+
model="happyhorse-text-to-video",
|
|
27
|
+
prompt="A horse galloping along a beach at sunset, cinematic",
|
|
28
|
+
aspect_ratio="16:9",
|
|
29
|
+
output_resolution="1080p",
|
|
30
|
+
)
|
|
31
|
+
status = client.text_to_video.get(task.id)
|
|
32
|
+
|
|
33
|
+
clip = client.image_to_video.create(
|
|
34
|
+
model="happyhorse-image-to-video",
|
|
35
|
+
first_frame_image_url="https://example.com/first-frame.jpg",
|
|
36
|
+
)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Use `create` to submit a task and return quickly, `get` to fetch the latest task
|
|
40
|
+
state, and `run` to create and poll until completion:
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
result = client.text_to_video.run(
|
|
44
|
+
model="happyhorse-text-to-video",
|
|
45
|
+
prompt="A serene mountain river at dawn",
|
|
46
|
+
)
|
|
47
|
+
print(result.videos[0].url)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
In web request handlers, prefer `create` plus webhook or later `get` polling so a
|
|
51
|
+
worker is not held open.
|
|
52
|
+
|
|
53
|
+
RunAPI-generated file URLs are temporary. Download and store generated videos in
|
|
54
|
+
your own durable storage within 7 days; do not treat returned URLs as long-term
|
|
55
|
+
assets.
|
|
56
|
+
|
|
57
|
+
## Language notes
|
|
58
|
+
|
|
59
|
+
Pass parameters as keyword arguments and catch the `runapi.happyhorse` error
|
|
60
|
+
classes when building video jobs or scripts. The available resources are
|
|
61
|
+
`text_to_video`, `image_to_video`, and `edit_video`. Keep `RUNAPI_API_KEY` in the
|
|
62
|
+
environment or your secret manager; never commit API keys or callback secrets.
|
|
63
|
+
|
|
64
|
+
## Links
|
|
65
|
+
|
|
66
|
+
- Model page: https://runapi.ai/models/happyhorse
|
|
67
|
+
- SDK docs: https://runapi.ai/docs#sdk-happyhorse
|
|
68
|
+
- Product docs: https://runapi.ai/docs#happyhorse
|
|
69
|
+
- Pricing and rate limits: https://runapi.ai/models/happyhorse
|
|
70
|
+
- Full catalog: https://runapi.ai/models
|
|
71
|
+
|
|
72
|
+
## License
|
|
73
|
+
|
|
74
|
+
Licensed under the Apache License, Version 2.0.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "runapi-happyhorse"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "HappyHorse text-to-video, image-to-video, and edit-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", "happyhorse", "text-to-video", "image-to-video", "edit-video", "ai", "sdk"]
|
|
14
|
+
dependencies = ["runapi-core"]
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Homepage = "https://runapi.ai/models/happyhorse"
|
|
18
|
+
Documentation = "https://runapi.ai/docs#sdk-happyhorse"
|
|
19
|
+
|
|
20
|
+
[tool.hatch.build.targets.wheel]
|
|
21
|
+
packages = ["src/runapi"]
|
|
22
|
+
|
|
23
|
+
[tool.uv]
|
|
24
|
+
package = true
|
|
25
|
+
|
|
26
|
+
[tool.uv.sources]
|
|
27
|
+
runapi-core = { path = "../runapi-core", editable = true }
|
|
28
|
+
|
|
29
|
+
[dependency-groups]
|
|
30
|
+
dev = ["pytest>=8"]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""HappyHorse 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 HappyHorseClient
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"HappyHorseClient",
|
|
17
|
+
"AuthenticationError",
|
|
18
|
+
"RateLimitError",
|
|
19
|
+
"InsufficientCreditsError",
|
|
20
|
+
"NotFoundError",
|
|
21
|
+
"ValidationError",
|
|
22
|
+
"TaskFailedError",
|
|
23
|
+
"TaskTimeoutError",
|
|
24
|
+
]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""HappyHorse 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.edit_video import EditVideo
|
|
10
|
+
from .resources.image_to_video import ImageToVideo
|
|
11
|
+
from .resources.text_to_video import TextToVideo
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class HappyHorseClient:
|
|
15
|
+
"""HappyHorse text-to-video, image-to-video, and edit-video client.
|
|
16
|
+
|
|
17
|
+
Example::
|
|
18
|
+
|
|
19
|
+
client = HappyHorseClient(api_key="sk-...")
|
|
20
|
+
result = client.text_to_video.run(
|
|
21
|
+
model="happyhorse-text-to-video", prompt="A horse galloping on a beach"
|
|
22
|
+
)
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, api_key: Optional[str] = None, **options: Any) -> None:
|
|
26
|
+
resolved_api_key = resolve_api_key(api_key)
|
|
27
|
+
client_options = ClientOptions(api_key=resolved_api_key, **options)
|
|
28
|
+
http = client_options.http_client or HttpClient(client_options)
|
|
29
|
+
self.text_to_video = TextToVideo(http)
|
|
30
|
+
self.image_to_video = ImageToVideo(http)
|
|
31
|
+
self.edit_video = EditVideo(http)
|
|
File without changes
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""HappyHorse edit-video resource."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict
|
|
6
|
+
|
|
7
|
+
from runapi.core import Resource, ValidationError
|
|
8
|
+
|
|
9
|
+
from ..types import (
|
|
10
|
+
AUDIO_SETTINGS,
|
|
11
|
+
EDIT_VIDEO_MODEL,
|
|
12
|
+
OUTPUT_RESOLUTIONS,
|
|
13
|
+
SEED_RANGE,
|
|
14
|
+
CompletedEditVideoResponse,
|
|
15
|
+
EditVideoResponse,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class EditVideo(Resource):
|
|
20
|
+
"""Edit a source video from a prompt with HappyHorse models."""
|
|
21
|
+
|
|
22
|
+
ENDPOINT = "/api/v1/happyhorse/edit_video"
|
|
23
|
+
|
|
24
|
+
RESPONSE_CLASS = EditVideoResponse
|
|
25
|
+
COMPLETED_RESPONSE_CLASS = CompletedEditVideoResponse
|
|
26
|
+
REFERENCE_IMAGE_RANGE = range(0, 6)
|
|
27
|
+
|
|
28
|
+
def run(self, **params: Any) -> Any:
|
|
29
|
+
"""Create an edit-video task and poll until it completes.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
**params: Edit-video parameters (model, prompt, source_video_url, ...).
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
The completed task with videos.
|
|
36
|
+
"""
|
|
37
|
+
task = self.create(**params)
|
|
38
|
+
return self._poll_until_complete(lambda: self.get(task.id))
|
|
39
|
+
|
|
40
|
+
def create(self, **params: Any) -> Any:
|
|
41
|
+
"""Create an edit-video task and return immediately with an ``id``.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
**params: Edit-video parameters (model, prompt, source_video_url, ...).
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
The task creation result with an id.
|
|
48
|
+
"""
|
|
49
|
+
compacted = self._compact_params(params)
|
|
50
|
+
self._validate_params(compacted)
|
|
51
|
+
return self._request("post", self.ENDPOINT, body=compacted)
|
|
52
|
+
|
|
53
|
+
def get(self, id: str) -> Any:
|
|
54
|
+
"""Fetch the current status of an edit-video task.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
id: The task id.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
The current task status.
|
|
61
|
+
"""
|
|
62
|
+
return self._request("get", f"{self.ENDPOINT}/{id}")
|
|
63
|
+
|
|
64
|
+
def _validate_params(self, params: Dict[str, Any]) -> None:
|
|
65
|
+
if params.get("model") != EDIT_VIDEO_MODEL:
|
|
66
|
+
raise ValidationError("model is required")
|
|
67
|
+
if not params.get("prompt"):
|
|
68
|
+
raise ValidationError("prompt is required")
|
|
69
|
+
if not params.get("source_video_url"):
|
|
70
|
+
raise ValidationError("source_video_url is required")
|
|
71
|
+
|
|
72
|
+
reference_image_urls = params.get("reference_image_urls")
|
|
73
|
+
if reference_image_urls and (
|
|
74
|
+
not isinstance(reference_image_urls, list)
|
|
75
|
+
or len(reference_image_urls) not in self.REFERENCE_IMAGE_RANGE
|
|
76
|
+
):
|
|
77
|
+
raise ValidationError(
|
|
78
|
+
f"reference_image_urls must include at most {self.REFERENCE_IMAGE_RANGE.stop - 1} entries"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
self._validate_optional(params, "output_resolution", OUTPUT_RESOLUTIONS)
|
|
82
|
+
self._validate_optional(params, "audio_setting", AUDIO_SETTINGS)
|
|
83
|
+
self._validate_integer_range(params, "seed", SEED_RANGE)
|
|
84
|
+
|
|
85
|
+
@staticmethod
|
|
86
|
+
def _validate_integer_range(params: Dict[str, Any], key: str, allowed: range) -> None:
|
|
87
|
+
value = params.get(key)
|
|
88
|
+
if value is None:
|
|
89
|
+
return
|
|
90
|
+
if isinstance(value, int) and not isinstance(value, bool) and value in allowed:
|
|
91
|
+
return
|
|
92
|
+
raise ValidationError(
|
|
93
|
+
f"{key} must be an integer between {allowed.start} and {allowed.stop - 1}"
|
|
94
|
+
)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""HappyHorse image-to-video resource."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict
|
|
6
|
+
|
|
7
|
+
from runapi.core import Resource, ValidationError
|
|
8
|
+
|
|
9
|
+
from ..types import (
|
|
10
|
+
DURATION_RANGE,
|
|
11
|
+
IMAGE_TO_VIDEO_MODEL,
|
|
12
|
+
OUTPUT_RESOLUTIONS,
|
|
13
|
+
SEED_RANGE,
|
|
14
|
+
CompletedImageToVideoResponse,
|
|
15
|
+
ImageToVideoResponse,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ImageToVideo(Resource):
|
|
20
|
+
"""Generate videos from a first-frame image with HappyHorse models."""
|
|
21
|
+
|
|
22
|
+
ENDPOINT = "/api/v1/happyhorse/image_to_video"
|
|
23
|
+
|
|
24
|
+
RESPONSE_CLASS = ImageToVideoResponse
|
|
25
|
+
COMPLETED_RESPONSE_CLASS = CompletedImageToVideoResponse
|
|
26
|
+
|
|
27
|
+
def run(self, **params: Any) -> Any:
|
|
28
|
+
"""Create an image-to-video task and poll until it completes.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
**params: Image-to-video parameters (model, first_frame_image_url, ...).
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
The completed task with videos.
|
|
35
|
+
"""
|
|
36
|
+
task = self.create(**params)
|
|
37
|
+
return self._poll_until_complete(lambda: self.get(task.id))
|
|
38
|
+
|
|
39
|
+
def create(self, **params: Any) -> Any:
|
|
40
|
+
"""Create an image-to-video task and return immediately with an ``id``.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
**params: Image-to-video parameters (model, first_frame_image_url, ...).
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
The task creation result with an id.
|
|
47
|
+
"""
|
|
48
|
+
compacted = self._compact_params(params)
|
|
49
|
+
self._validate_params(compacted)
|
|
50
|
+
return self._request("post", self.ENDPOINT, body=compacted)
|
|
51
|
+
|
|
52
|
+
def get(self, id: str) -> Any:
|
|
53
|
+
"""Fetch the current status of an image-to-video task.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
id: The task id.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
The current task status.
|
|
60
|
+
"""
|
|
61
|
+
return self._request("get", f"{self.ENDPOINT}/{id}")
|
|
62
|
+
|
|
63
|
+
def _validate_params(self, params: Dict[str, Any]) -> None:
|
|
64
|
+
if params.get("model") != IMAGE_TO_VIDEO_MODEL:
|
|
65
|
+
raise ValidationError("model is required")
|
|
66
|
+
if not params.get("first_frame_image_url"):
|
|
67
|
+
raise ValidationError("first_frame_image_url is required")
|
|
68
|
+
|
|
69
|
+
self._validate_optional(params, "output_resolution", OUTPUT_RESOLUTIONS)
|
|
70
|
+
self._validate_integer_range(params, "duration_seconds", DURATION_RANGE)
|
|
71
|
+
self._validate_integer_range(params, "seed", SEED_RANGE)
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def _validate_integer_range(params: Dict[str, Any], key: str, allowed: range) -> None:
|
|
75
|
+
value = params.get(key)
|
|
76
|
+
if value is None:
|
|
77
|
+
return
|
|
78
|
+
if isinstance(value, int) and not isinstance(value, bool) and value in allowed:
|
|
79
|
+
return
|
|
80
|
+
raise ValidationError(
|
|
81
|
+
f"{key} must be an integer between {allowed.start} and {allowed.stop - 1}"
|
|
82
|
+
)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""HappyHorse text-to-video resource."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict
|
|
6
|
+
|
|
7
|
+
from runapi.core import Resource, ValidationError
|
|
8
|
+
|
|
9
|
+
from ..types import (
|
|
10
|
+
ASPECT_RATIOS,
|
|
11
|
+
CHARACTER_MODEL,
|
|
12
|
+
DURATION_RANGE,
|
|
13
|
+
OUTPUT_RESOLUTIONS,
|
|
14
|
+
SEED_RANGE,
|
|
15
|
+
TEXT_TO_VIDEO_MODELS,
|
|
16
|
+
CompletedTextToVideoResponse,
|
|
17
|
+
TextToVideoResponse,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TextToVideo(Resource):
|
|
22
|
+
"""Generate videos from text prompts with HappyHorse models."""
|
|
23
|
+
|
|
24
|
+
ENDPOINT = "/api/v1/happyhorse/text_to_video"
|
|
25
|
+
|
|
26
|
+
RESPONSE_CLASS = TextToVideoResponse
|
|
27
|
+
COMPLETED_RESPONSE_CLASS = CompletedTextToVideoResponse
|
|
28
|
+
REFERENCE_IMAGE_URLS_RANGE = range(1, 10)
|
|
29
|
+
|
|
30
|
+
def run(self, **params: Any) -> Any:
|
|
31
|
+
"""Create a text-to-video task and poll until it completes.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
**params: Text-to-video parameters (model, prompt, ...).
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
The completed task with videos.
|
|
38
|
+
"""
|
|
39
|
+
task = self.create(**params)
|
|
40
|
+
return self._poll_until_complete(lambda: self.get(task.id))
|
|
41
|
+
|
|
42
|
+
def create(self, **params: Any) -> Any:
|
|
43
|
+
"""Create a text-to-video task and return immediately with an ``id``.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
**params: Text-to-video parameters (model, prompt, ...).
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
The task creation result with an id.
|
|
50
|
+
"""
|
|
51
|
+
compacted = self._compact_params(params)
|
|
52
|
+
self._validate_params(compacted)
|
|
53
|
+
return self._request("post", self.ENDPOINT, body=compacted)
|
|
54
|
+
|
|
55
|
+
def get(self, id: str) -> Any:
|
|
56
|
+
"""Fetch the current status of a text-to-video task.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
id: The task id.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
The current task status.
|
|
63
|
+
"""
|
|
64
|
+
return self._request("get", f"{self.ENDPOINT}/{id}")
|
|
65
|
+
|
|
66
|
+
def _validate_params(self, params: Dict[str, Any]) -> None:
|
|
67
|
+
model = params.get("model")
|
|
68
|
+
if model not in TEXT_TO_VIDEO_MODELS:
|
|
69
|
+
raise ValidationError("model is required")
|
|
70
|
+
if not params.get("prompt"):
|
|
71
|
+
raise ValidationError("prompt is required")
|
|
72
|
+
|
|
73
|
+
reference_image_urls = params.get("reference_image_urls")
|
|
74
|
+
if model == CHARACTER_MODEL:
|
|
75
|
+
if not (
|
|
76
|
+
isinstance(reference_image_urls, list)
|
|
77
|
+
and len(reference_image_urls) in self.REFERENCE_IMAGE_URLS_RANGE
|
|
78
|
+
):
|
|
79
|
+
raise ValidationError(
|
|
80
|
+
f"reference_image_urls must include between {self.REFERENCE_IMAGE_URLS_RANGE.start} "
|
|
81
|
+
f"and {self.REFERENCE_IMAGE_URLS_RANGE.stop - 1} entries"
|
|
82
|
+
)
|
|
83
|
+
elif reference_image_urls:
|
|
84
|
+
raise ValidationError(f"reference_image_urls is only supported for {CHARACTER_MODEL}")
|
|
85
|
+
|
|
86
|
+
self._validate_optional(params, "output_resolution", OUTPUT_RESOLUTIONS)
|
|
87
|
+
self._validate_optional(params, "aspect_ratio", ASPECT_RATIOS)
|
|
88
|
+
self._validate_integer_range(params, "duration_seconds", DURATION_RANGE)
|
|
89
|
+
self._validate_integer_range(params, "seed", SEED_RANGE)
|
|
90
|
+
|
|
91
|
+
@staticmethod
|
|
92
|
+
def _validate_integer_range(params: Dict[str, Any], key: str, allowed: range) -> None:
|
|
93
|
+
value = params.get(key)
|
|
94
|
+
if value is None:
|
|
95
|
+
return
|
|
96
|
+
if isinstance(value, int) and not isinstance(value, bool) and value in allowed:
|
|
97
|
+
return
|
|
98
|
+
raise ValidationError(
|
|
99
|
+
f"{key} must be an integer between {allowed.start} and {allowed.stop - 1}"
|
|
100
|
+
)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""HappyHorse model lists, enums, and response models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from runapi.core import BaseModel, TaskResponse, optional, required
|
|
6
|
+
|
|
7
|
+
TEXT_TO_VIDEO_MODEL = "happyhorse-text-to-video"
|
|
8
|
+
CHARACTER_MODEL = "happyhorse-character"
|
|
9
|
+
TEXT_TO_VIDEO_MODELS = [TEXT_TO_VIDEO_MODEL, CHARACTER_MODEL]
|
|
10
|
+
IMAGE_TO_VIDEO_MODEL = "happyhorse-image-to-video"
|
|
11
|
+
EDIT_VIDEO_MODEL = "happyhorse-edit-video"
|
|
12
|
+
OUTPUT_RESOLUTIONS = ["720p", "1080p"]
|
|
13
|
+
ASPECT_RATIOS = ["16:9", "9:16", "1:1", "4:3", "3:4"]
|
|
14
|
+
AUDIO_SETTINGS = ["auto", "original"]
|
|
15
|
+
DURATION_RANGE = range(3, 16)
|
|
16
|
+
SEED_RANGE = range(0, 2_147_483_648)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MediaUrl(BaseModel):
|
|
20
|
+
url = optional(str)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TextToVideoResponse(TaskResponse):
|
|
24
|
+
"""Response for a video generation task."""
|
|
25
|
+
|
|
26
|
+
id = required(str)
|
|
27
|
+
status = optional(str, enum=lambda: TaskResponse.Status.ALL)
|
|
28
|
+
videos = optional([lambda: MediaUrl])
|
|
29
|
+
error = optional(str)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CompletedTextToVideoResponse(TextToVideoResponse):
|
|
33
|
+
"""Narrowed response from ``run()`` once polling observes completion."""
|
|
34
|
+
|
|
35
|
+
videos = required([lambda: MediaUrl])
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
ImageToVideoResponse = TextToVideoResponse
|
|
39
|
+
CompletedImageToVideoResponse = CompletedTextToVideoResponse
|
|
40
|
+
EditVideoResponse = TextToVideoResponse
|
|
41
|
+
CompletedEditVideoResponse = CompletedTextToVideoResponse
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from runapi.core import config
|
|
4
|
+
from runapi.core.errors import AuthenticationError, ValidationError
|
|
5
|
+
from runapi.happyhorse import HappyHorseClient
|
|
6
|
+
from runapi.happyhorse.resources.edit_video import EditVideo
|
|
7
|
+
from runapi.happyhorse.resources.image_to_video import ImageToVideo
|
|
8
|
+
from runapi.happyhorse.resources.text_to_video import TextToVideo
|
|
9
|
+
from runapi.happyhorse.types import CompletedTextToVideoResponse, TextToVideoResponse
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FakeHttp:
|
|
13
|
+
def __init__(self, *responses):
|
|
14
|
+
self._responses = list(responses)
|
|
15
|
+
self.calls = []
|
|
16
|
+
|
|
17
|
+
def request(self, method, path, body=None, options=None):
|
|
18
|
+
self.calls.append((method, path, body))
|
|
19
|
+
if self._responses:
|
|
20
|
+
return self._responses.pop(0)
|
|
21
|
+
return {"id": "task_1", "status": "pending"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.fixture(autouse=True)
|
|
25
|
+
def reset_config(monkeypatch):
|
|
26
|
+
monkeypatch.delenv("RUNAPI_API_KEY", raising=False)
|
|
27
|
+
monkeypatch.setattr(config, "api_key", None)
|
|
28
|
+
yield
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# --- auth -----------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_accepts_api_key_parameter():
|
|
35
|
+
assert isinstance(HappyHorseClient(api_key="k", http_client=FakeHttp()), HappyHorseClient)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_falls_back_to_global(monkeypatch):
|
|
39
|
+
monkeypatch.setattr(config, "api_key", "global-key")
|
|
40
|
+
assert isinstance(HappyHorseClient(http_client=FakeHttp()), HappyHorseClient)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_falls_back_to_env(monkeypatch):
|
|
44
|
+
monkeypatch.setenv("RUNAPI_API_KEY", "env-key")
|
|
45
|
+
assert isinstance(HappyHorseClient(http_client=FakeHttp()), HappyHorseClient)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_raises_without_api_key():
|
|
49
|
+
with pytest.raises(AuthenticationError, match="API key is required"):
|
|
50
|
+
HappyHorseClient()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# --- injection / accessors ------------------------------------------------
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_uses_injected_http_client():
|
|
57
|
+
fake = FakeHttp()
|
|
58
|
+
client = HappyHorseClient(api_key="k", http_client=fake)
|
|
59
|
+
assert client.text_to_video._http is fake
|
|
60
|
+
assert client.image_to_video._http is fake
|
|
61
|
+
assert client.edit_video._http is fake
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_exposes_resource_accessors():
|
|
65
|
+
client = HappyHorseClient(api_key="k", http_client=FakeHttp())
|
|
66
|
+
assert isinstance(client.text_to_video, TextToVideo)
|
|
67
|
+
assert isinstance(client.image_to_video, ImageToVideo)
|
|
68
|
+
assert isinstance(client.edit_video, EditVideo)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# --- request shapes -------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_text_to_video_create_posts_compacted_body():
|
|
75
|
+
fake = FakeHttp({"id": "t1", "status": "pending"})
|
|
76
|
+
client = HappyHorseClient(api_key="k", http_client=fake)
|
|
77
|
+
result = client.text_to_video.create(
|
|
78
|
+
model="happyhorse-text-to-video",
|
|
79
|
+
prompt="a horse",
|
|
80
|
+
aspect_ratio="16:9",
|
|
81
|
+
seed=None,
|
|
82
|
+
)
|
|
83
|
+
assert fake.calls == [
|
|
84
|
+
(
|
|
85
|
+
"post",
|
|
86
|
+
"/api/v1/happyhorse/text_to_video",
|
|
87
|
+
{"model": "happyhorse-text-to-video", "prompt": "a horse", "aspect_ratio": "16:9"},
|
|
88
|
+
),
|
|
89
|
+
]
|
|
90
|
+
assert isinstance(result, TextToVideoResponse)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_text_to_video_get_fetches_by_id():
|
|
94
|
+
fake = FakeHttp({"id": "t1", "status": "processing"})
|
|
95
|
+
client = HappyHorseClient(api_key="k", http_client=fake)
|
|
96
|
+
client.text_to_video.get("t1")
|
|
97
|
+
assert fake.calls == [("get", "/api/v1/happyhorse/text_to_video/t1", None)]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_image_to_video_create_posts_compacted_body():
|
|
101
|
+
fake = FakeHttp({"id": "t1", "status": "pending"})
|
|
102
|
+
client = HappyHorseClient(api_key="k", http_client=fake)
|
|
103
|
+
client.image_to_video.create(
|
|
104
|
+
model="happyhorse-image-to-video",
|
|
105
|
+
first_frame_image_url="https://example.com/a.jpg",
|
|
106
|
+
output_resolution="720p",
|
|
107
|
+
)
|
|
108
|
+
assert fake.calls == [
|
|
109
|
+
(
|
|
110
|
+
"post",
|
|
111
|
+
"/api/v1/happyhorse/image_to_video",
|
|
112
|
+
{
|
|
113
|
+
"model": "happyhorse-image-to-video",
|
|
114
|
+
"first_frame_image_url": "https://example.com/a.jpg",
|
|
115
|
+
"output_resolution": "720p",
|
|
116
|
+
},
|
|
117
|
+
),
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_edit_video_create_posts_compacted_body():
|
|
122
|
+
fake = FakeHttp({"id": "t1", "status": "pending"})
|
|
123
|
+
client = HappyHorseClient(api_key="k", http_client=fake)
|
|
124
|
+
client.edit_video.create(
|
|
125
|
+
model="happyhorse-edit-video",
|
|
126
|
+
prompt="brighten it",
|
|
127
|
+
source_video_url="https://example.com/v.mp4",
|
|
128
|
+
)
|
|
129
|
+
assert fake.calls == [
|
|
130
|
+
(
|
|
131
|
+
"post",
|
|
132
|
+
"/api/v1/happyhorse/edit_video",
|
|
133
|
+
{
|
|
134
|
+
"model": "happyhorse-edit-video",
|
|
135
|
+
"prompt": "brighten it",
|
|
136
|
+
"source_video_url": "https://example.com/v.mp4",
|
|
137
|
+
},
|
|
138
|
+
),
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_run_narrows_completed_type():
|
|
143
|
+
fake = FakeHttp(
|
|
144
|
+
{"id": "t1", "status": "pending"},
|
|
145
|
+
{"id": "t1", "status": "completed", "videos": [{"url": "https://x/y.mp4"}]},
|
|
146
|
+
)
|
|
147
|
+
client = HappyHorseClient(api_key="k", http_client=fake)
|
|
148
|
+
result = client.text_to_video.run(model="happyhorse-text-to-video", prompt="a serene river")
|
|
149
|
+
assert isinstance(result, CompletedTextToVideoResponse)
|
|
150
|
+
assert result.videos[0].url == "https://x/y.mp4"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# --- validation -----------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_text_to_video_rejects_unknown_model():
|
|
157
|
+
client = HappyHorseClient(api_key="k", http_client=FakeHttp())
|
|
158
|
+
with pytest.raises(ValidationError, match="model is required"):
|
|
159
|
+
client.text_to_video.create(model="nope", prompt="hi")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_text_to_video_requires_prompt():
|
|
163
|
+
client = HappyHorseClient(api_key="k", http_client=FakeHttp())
|
|
164
|
+
with pytest.raises(ValidationError, match="prompt is required"):
|
|
165
|
+
client.text_to_video.create(model="happyhorse-text-to-video")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_text_to_video_rejects_reference_images_for_non_character():
|
|
169
|
+
client = HappyHorseClient(api_key="k", http_client=FakeHttp())
|
|
170
|
+
with pytest.raises(ValidationError, match="reference_image_urls is only supported for happyhorse-character"):
|
|
171
|
+
client.text_to_video.create(
|
|
172
|
+
model="happyhorse-text-to-video",
|
|
173
|
+
prompt="hi",
|
|
174
|
+
reference_image_urls=["https://x/a.jpg"],
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_character_model_requires_reference_images_range():
|
|
179
|
+
client = HappyHorseClient(api_key="k", http_client=FakeHttp())
|
|
180
|
+
with pytest.raises(ValidationError, match="reference_image_urls must include between 1 and 9 entries"):
|
|
181
|
+
client.text_to_video.create(model="happyhorse-character", prompt="hi", reference_image_urls=[])
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_text_to_video_duration_range():
|
|
185
|
+
client = HappyHorseClient(api_key="k", http_client=FakeHttp())
|
|
186
|
+
with pytest.raises(ValidationError, match="duration_seconds must be an integer between 3 and 15"):
|
|
187
|
+
client.text_to_video.create(model="happyhorse-text-to-video", prompt="hi", duration_seconds=99)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_text_to_video_rejects_invalid_output_resolution():
|
|
191
|
+
client = HappyHorseClient(api_key="k", http_client=FakeHttp())
|
|
192
|
+
with pytest.raises(ValidationError, match="Invalid output_resolution"):
|
|
193
|
+
client.text_to_video.create(
|
|
194
|
+
model="happyhorse-text-to-video", prompt="hi", output_resolution="4k"
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_image_to_video_requires_model():
|
|
199
|
+
client = HappyHorseClient(api_key="k", http_client=FakeHttp())
|
|
200
|
+
with pytest.raises(ValidationError, match="model is required"):
|
|
201
|
+
client.image_to_video.create(model="wrong", first_frame_image_url="https://x/a.jpg")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def test_image_to_video_requires_first_frame():
|
|
205
|
+
client = HappyHorseClient(api_key="k", http_client=FakeHttp())
|
|
206
|
+
with pytest.raises(ValidationError, match="first_frame_image_url is required"):
|
|
207
|
+
client.image_to_video.create(model="happyhorse-image-to-video")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_edit_video_requires_source_video_url():
|
|
211
|
+
client = HappyHorseClient(api_key="k", http_client=FakeHttp())
|
|
212
|
+
with pytest.raises(ValidationError, match="source_video_url is required"):
|
|
213
|
+
client.edit_video.create(model="happyhorse-edit-video", prompt="brighten")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def test_edit_video_reference_images_max():
|
|
217
|
+
client = HappyHorseClient(api_key="k", http_client=FakeHttp())
|
|
218
|
+
with pytest.raises(ValidationError, match="reference_image_urls must include at most 5 entries"):
|
|
219
|
+
client.edit_video.create(
|
|
220
|
+
model="happyhorse-edit-video",
|
|
221
|
+
prompt="brighten",
|
|
222
|
+
source_video_url="https://x/v.mp4",
|
|
223
|
+
reference_image_urls=["a", "b", "c", "d", "e", "f"],
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def test_edit_video_rejects_invalid_audio_setting():
|
|
228
|
+
client = HappyHorseClient(api_key="k", http_client=FakeHttp())
|
|
229
|
+
with pytest.raises(ValidationError, match="Invalid audio_setting"):
|
|
230
|
+
client.edit_video.create(
|
|
231
|
+
model="happyhorse-edit-video",
|
|
232
|
+
prompt="brighten",
|
|
233
|
+
source_video_url="https://x/v.mp4",
|
|
234
|
+
audio_setting="muted",
|
|
235
|
+
)
|