varya-avataar 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.
- varya_avataar-0.1.0/.gitignore +21 -0
- varya_avataar-0.1.0/LICENSE +21 -0
- varya_avataar-0.1.0/PKG-INFO +170 -0
- varya_avataar-0.1.0/README.md +146 -0
- varya_avataar-0.1.0/USAGE.md +157 -0
- varya_avataar-0.1.0/pyproject.toml +43 -0
- varya_avataar-0.1.0/src/varya/__init__.py +10 -0
- varya_avataar-0.1.0/src/varya/cli.py +98 -0
- varya_avataar-0.1.0/src/varya/client.py +313 -0
- varya_avataar-0.1.0/tests/test_client.py +180 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.eggs/
|
|
6
|
+
build/
|
|
7
|
+
dist/
|
|
8
|
+
.pytest_cache/
|
|
9
|
+
.mypy_cache/
|
|
10
|
+
.ruff_cache/
|
|
11
|
+
.venv/
|
|
12
|
+
venv/
|
|
13
|
+
env/
|
|
14
|
+
|
|
15
|
+
# Local artifacts
|
|
16
|
+
*.mp4
|
|
17
|
+
.DS_Store
|
|
18
|
+
|
|
19
|
+
# Local-only dev scripts & examples (not part of the published package)
|
|
20
|
+
smoke_test.py
|
|
21
|
+
examples/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Varya
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: varya-avataar
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the Varya video generation API
|
|
5
|
+
Project-URL: Homepage, https://varya.avataar.ai
|
|
6
|
+
Project-URL: Documentation, https://github.com/SoulVisionCreations/varya-python#readme
|
|
7
|
+
Project-URL: Source, https://github.com/SoulVisionCreations/varya-python
|
|
8
|
+
Author: Varya
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ai,api,generation,sdk,text-to-video,varya,video
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Topic :: Multimedia :: Video
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Requires-Python: >=3.8
|
|
20
|
+
Requires-Dist: requests>=2.25
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# varya-avataar (Python)
|
|
26
|
+
|
|
27
|
+
Python client for the **Varya** video generation API. Submit a text (or image)
|
|
28
|
+
prompt, and the client handles the async job lifecycle — submit, poll, and
|
|
29
|
+
download — for you.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install varya-avataar
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
> Installs as `varya-avataar` on PyPI but **imports as `varya`**:
|
|
38
|
+
> `from varya import VaryaClient`.
|
|
39
|
+
|
|
40
|
+
While developing locally (from this folder):
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install -e .
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Requires Python 3.8+ and `[requests](https://pypi.org/project/requests/)`.
|
|
47
|
+
|
|
48
|
+
## Authentication
|
|
49
|
+
|
|
50
|
+
Create a key in the Varya web app (**Account menu → API key**). It looks like
|
|
51
|
+
`sk_live_…`. Keep it secret; it draws down your account's credit balance.
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
export VARYA_API_KEY=sk_live_xxxxx
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Quick start
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from varya import VaryaClient
|
|
61
|
+
|
|
62
|
+
client = VaryaClient(api_key="sk_live_xxxxx")
|
|
63
|
+
|
|
64
|
+
result = client.generate_video(
|
|
65
|
+
"a doctor making a shorts video on health",
|
|
66
|
+
resolution="720p", # "480p" | "720p"
|
|
67
|
+
duration=5, # seconds, 1–5
|
|
68
|
+
on_status=print, # optional progress callback
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
print(result["video_url"])
|
|
72
|
+
client.download(result["video_url"], "out.mp4")
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
> For a full end-to-end walkthrough (text-to-video **and** image-to-video,
|
|
76
|
+
> saving the output, error handling), see **[USAGE.md](./USAGE.md)**.
|
|
77
|
+
|
|
78
|
+
`VaryaClient` is also a context manager (`with VaryaClient(...) as client:`)
|
|
79
|
+
which closes the underlying HTTP session on exit.
|
|
80
|
+
|
|
81
|
+
## CLI
|
|
82
|
+
|
|
83
|
+
Installing the package also installs a `varya` command:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
# create a new video and download it
|
|
87
|
+
varya "a cat surfing a wave" --resolution 720p --output out.mp4
|
|
88
|
+
|
|
89
|
+
# resume / fetch an existing generation (no prompt, nothing re-charged)
|
|
90
|
+
varya --get gen_xxxxxxxx --output out.mp4
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Run `varya --help` for all flags (`--duration`, `--resolution`, `--style`,
|
|
94
|
+
`--seed`, `--enhance`, `--watermark`, `--image`, `--timeout`, …).
|
|
95
|
+
|
|
96
|
+
The key is read from `--key` or `$VARYA_API_KEY`; the base URL from
|
|
97
|
+
`--base-url` or `$VARYA_BASE_URL`.
|
|
98
|
+
|
|
99
|
+
## API
|
|
100
|
+
|
|
101
|
+
### `VaryaClient(api_key, base_url=..., auth_scheme="x-api-key", connect_timeout=15, read_timeout=60)`
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
| Method | Description |
|
|
105
|
+
| ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
|
|
106
|
+
| `generate_video(prompt, **opts)` | Submit **and** wait. Returns the final result `dict`. The 90% path. |
|
|
107
|
+
| `submit(prompt, **opts)` | `POST /api/generations`; returns `{ generation_id, status, credits_charged, … }` without waiting. |
|
|
108
|
+
| `status(generation_id)` | `GET /generations/{id}`; current status/result. |
|
|
109
|
+
| `wait(generation_id, poll_interval=2, timeout=600, on_status=None)` | Poll until `completed`/`failed`. |
|
|
110
|
+
| `download(url, dest)` | Stream a finished video URL to a local file. |
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
**Generation options** (for `generate_video` / `submit`): `resolution`
|
|
114
|
+
(`"480p"` / `"720p"`), `duration` (1–5 s), `style`, `seed`, `enhance`,
|
|
115
|
+
`apply_watermark` (selects the logo; output is always watermarked), `image_path`
|
|
116
|
+
(image-to-video).
|
|
117
|
+
|
|
118
|
+
> Single-clip only: the client targets the streamlined `POST /api/generations`
|
|
119
|
+
> endpoint, so there is no compare / long-form / continuation mode and `fps` is
|
|
120
|
+
> fixed server-side. `duration` must be 1–5 (out-of-range raises
|
|
121
|
+
> `LONG_FORM_NOT_SUPPORTED` before any request is sent).
|
|
122
|
+
|
|
123
|
+
### Result shape
|
|
124
|
+
|
|
125
|
+
- **completed**: `{ "status": "completed", "video_url": "https://…" }`
|
|
126
|
+
- **failed**: `{ "status": "failed", "error": "…", "error_code": "…" }`
|
|
127
|
+
|
|
128
|
+
### Errors
|
|
129
|
+
|
|
130
|
+
Business/API failures raise `VaryaError` with a `.code` and `.status`:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
from varya import VaryaError
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
client.generate_video("…")
|
|
137
|
+
except VaryaError as e:
|
|
138
|
+
if e.code == "INSUFFICIENT_CREDITS":
|
|
139
|
+
... # top up
|
|
140
|
+
elif e.code == "NSFW_CONTENT_BLOCKED":
|
|
141
|
+
... # adjust the prompt/image
|
|
142
|
+
else:
|
|
143
|
+
raise
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Notes
|
|
147
|
+
|
|
148
|
+
- **Seed.** A `seed` is always sent. When you don't pass one it's randomized
|
|
149
|
+
client-side (in `[100, 1_000_000)`, matching the web app) and returned on the
|
|
150
|
+
result as `seed`, so you can reproduce a run by passing that same `seed` back.
|
|
151
|
+
- **Async by design.** Jobs are queued; the client polls every ~2s — raise
|
|
152
|
+
`timeout` if a job needs longer.
|
|
153
|
+
- **Resilient polling.** One keep-alive connection is reused, idempotent `GET`s
|
|
154
|
+
are retried with backoff, and transient network blips don't abort a run.
|
|
155
|
+
`POST /api/generations` is never auto-retried, so a job is never double-charged.
|
|
156
|
+
- **Auth header.** Defaults to `X-API-Key`. If your deployment expects
|
|
157
|
+
`Authorization: Bearer`, pass `auth_scheme="bearer"`.
|
|
158
|
+
- **Cost.** 1 credit (480p) / 2 (720p). The exact charge is returned as
|
|
159
|
+
`credits_charged`.
|
|
160
|
+
|
|
161
|
+
## Development
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
pip install -e ".[dev]"
|
|
165
|
+
pytest
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## License
|
|
169
|
+
|
|
170
|
+
MIT
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# varya-avataar (Python)
|
|
2
|
+
|
|
3
|
+
Python client for the **Varya** video generation API. Submit a text (or image)
|
|
4
|
+
prompt, and the client handles the async job lifecycle — submit, poll, and
|
|
5
|
+
download — for you.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install varya-avataar
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
> Installs as `varya-avataar` on PyPI but **imports as `varya`**:
|
|
14
|
+
> `from varya import VaryaClient`.
|
|
15
|
+
|
|
16
|
+
While developing locally (from this folder):
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install -e .
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Requires Python 3.8+ and `[requests](https://pypi.org/project/requests/)`.
|
|
23
|
+
|
|
24
|
+
## Authentication
|
|
25
|
+
|
|
26
|
+
Create a key in the Varya web app (**Account menu → API key**). It looks like
|
|
27
|
+
`sk_live_…`. Keep it secret; it draws down your account's credit balance.
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
export VARYA_API_KEY=sk_live_xxxxx
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Quick start
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from varya import VaryaClient
|
|
37
|
+
|
|
38
|
+
client = VaryaClient(api_key="sk_live_xxxxx")
|
|
39
|
+
|
|
40
|
+
result = client.generate_video(
|
|
41
|
+
"a doctor making a shorts video on health",
|
|
42
|
+
resolution="720p", # "480p" | "720p"
|
|
43
|
+
duration=5, # seconds, 1–5
|
|
44
|
+
on_status=print, # optional progress callback
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
print(result["video_url"])
|
|
48
|
+
client.download(result["video_url"], "out.mp4")
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
> For a full end-to-end walkthrough (text-to-video **and** image-to-video,
|
|
52
|
+
> saving the output, error handling), see **[USAGE.md](./USAGE.md)**.
|
|
53
|
+
|
|
54
|
+
`VaryaClient` is also a context manager (`with VaryaClient(...) as client:`)
|
|
55
|
+
which closes the underlying HTTP session on exit.
|
|
56
|
+
|
|
57
|
+
## CLI
|
|
58
|
+
|
|
59
|
+
Installing the package also installs a `varya` command:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
# create a new video and download it
|
|
63
|
+
varya "a cat surfing a wave" --resolution 720p --output out.mp4
|
|
64
|
+
|
|
65
|
+
# resume / fetch an existing generation (no prompt, nothing re-charged)
|
|
66
|
+
varya --get gen_xxxxxxxx --output out.mp4
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Run `varya --help` for all flags (`--duration`, `--resolution`, `--style`,
|
|
70
|
+
`--seed`, `--enhance`, `--watermark`, `--image`, `--timeout`, …).
|
|
71
|
+
|
|
72
|
+
The key is read from `--key` or `$VARYA_API_KEY`; the base URL from
|
|
73
|
+
`--base-url` or `$VARYA_BASE_URL`.
|
|
74
|
+
|
|
75
|
+
## API
|
|
76
|
+
|
|
77
|
+
### `VaryaClient(api_key, base_url=..., auth_scheme="x-api-key", connect_timeout=15, read_timeout=60)`
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
| Method | Description |
|
|
81
|
+
| ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
|
|
82
|
+
| `generate_video(prompt, **opts)` | Submit **and** wait. Returns the final result `dict`. The 90% path. |
|
|
83
|
+
| `submit(prompt, **opts)` | `POST /api/generations`; returns `{ generation_id, status, credits_charged, … }` without waiting. |
|
|
84
|
+
| `status(generation_id)` | `GET /generations/{id}`; current status/result. |
|
|
85
|
+
| `wait(generation_id, poll_interval=2, timeout=600, on_status=None)` | Poll until `completed`/`failed`. |
|
|
86
|
+
| `download(url, dest)` | Stream a finished video URL to a local file. |
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
**Generation options** (for `generate_video` / `submit`): `resolution`
|
|
90
|
+
(`"480p"` / `"720p"`), `duration` (1–5 s), `style`, `seed`, `enhance`,
|
|
91
|
+
`apply_watermark` (selects the logo; output is always watermarked), `image_path`
|
|
92
|
+
(image-to-video).
|
|
93
|
+
|
|
94
|
+
> Single-clip only: the client targets the streamlined `POST /api/generations`
|
|
95
|
+
> endpoint, so there is no compare / long-form / continuation mode and `fps` is
|
|
96
|
+
> fixed server-side. `duration` must be 1–5 (out-of-range raises
|
|
97
|
+
> `LONG_FORM_NOT_SUPPORTED` before any request is sent).
|
|
98
|
+
|
|
99
|
+
### Result shape
|
|
100
|
+
|
|
101
|
+
- **completed**: `{ "status": "completed", "video_url": "https://…" }`
|
|
102
|
+
- **failed**: `{ "status": "failed", "error": "…", "error_code": "…" }`
|
|
103
|
+
|
|
104
|
+
### Errors
|
|
105
|
+
|
|
106
|
+
Business/API failures raise `VaryaError` with a `.code` and `.status`:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from varya import VaryaError
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
client.generate_video("…")
|
|
113
|
+
except VaryaError as e:
|
|
114
|
+
if e.code == "INSUFFICIENT_CREDITS":
|
|
115
|
+
... # top up
|
|
116
|
+
elif e.code == "NSFW_CONTENT_BLOCKED":
|
|
117
|
+
... # adjust the prompt/image
|
|
118
|
+
else:
|
|
119
|
+
raise
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Notes
|
|
123
|
+
|
|
124
|
+
- **Seed.** A `seed` is always sent. When you don't pass one it's randomized
|
|
125
|
+
client-side (in `[100, 1_000_000)`, matching the web app) and returned on the
|
|
126
|
+
result as `seed`, so you can reproduce a run by passing that same `seed` back.
|
|
127
|
+
- **Async by design.** Jobs are queued; the client polls every ~2s — raise
|
|
128
|
+
`timeout` if a job needs longer.
|
|
129
|
+
- **Resilient polling.** One keep-alive connection is reused, idempotent `GET`s
|
|
130
|
+
are retried with backoff, and transient network blips don't abort a run.
|
|
131
|
+
`POST /api/generations` is never auto-retried, so a job is never double-charged.
|
|
132
|
+
- **Auth header.** Defaults to `X-API-Key`. If your deployment expects
|
|
133
|
+
`Authorization: Bearer`, pass `auth_scheme="bearer"`.
|
|
134
|
+
- **Cost.** 1 credit (480p) / 2 (720p). The exact charge is returned as
|
|
135
|
+
`credits_charged`.
|
|
136
|
+
|
|
137
|
+
## Development
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
pip install -e ".[dev]"
|
|
141
|
+
pytest
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## License
|
|
145
|
+
|
|
146
|
+
MIT
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# Using `varya` (Python)
|
|
2
|
+
|
|
3
|
+
A complete, copy-pasteable walkthrough that mirrors a real end-to-end run:
|
|
4
|
+
submit a prompt, watch the status, and save the finished MP4. Covers both
|
|
5
|
+
**text-to-video (T2V)** and **image-to-video (I2V)**.
|
|
6
|
+
|
|
7
|
+
> Prerequisites: Python 3.8+ and an API key (`sk_live_…`) from the Varya web
|
|
8
|
+
> app (**Account menu → API key**).
|
|
9
|
+
|
|
10
|
+
## 1. Install & set your key
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install varya-avataar # installs as `varya-avataar`, imports as `varya`
|
|
14
|
+
export VARYA_API_KEY=sk_live_xxxxx
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Optionally override the API base URL (otherwise the SDK uses its built-in
|
|
18
|
+
default):
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
export VARYA_BASE_URL=https://your-base-url # only if you need a non-default host
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## 2. Text-to-video, end-to-end
|
|
25
|
+
|
|
26
|
+
This is the 90% path: `generate_video` submits the job **and** waits for it to
|
|
27
|
+
finish, then you download the result. `VaryaClient` is a context manager, so
|
|
28
|
+
use `with` to close the HTTP session cleanly.
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
import os
|
|
32
|
+
import sys
|
|
33
|
+
|
|
34
|
+
from varya import VaryaClient, VaryaError
|
|
35
|
+
|
|
36
|
+
api_key = os.environ["VARYA_API_KEY"]
|
|
37
|
+
|
|
38
|
+
with VaryaClient(api_key) as client:
|
|
39
|
+
try:
|
|
40
|
+
result = client.generate_video(
|
|
41
|
+
"a doctor making a shorts video on health",
|
|
42
|
+
resolution="480p", # "480p" (1 credit) | "720p" (2 credits)
|
|
43
|
+
duration=5, # seconds, 1–5
|
|
44
|
+
style="none", # "none" | "cinematic" | "anime" | …
|
|
45
|
+
seed=1762, # omit (or None) for a random, reported seed
|
|
46
|
+
on_status=lambda s: print(f"status: {s}"),
|
|
47
|
+
)
|
|
48
|
+
except VaryaError as exc:
|
|
49
|
+
print(f"ERROR [{exc.code}] (HTTP {exc.status}): {exc}", file=sys.stderr)
|
|
50
|
+
raise SystemExit(1)
|
|
51
|
+
|
|
52
|
+
if result.get("status") == "failed":
|
|
53
|
+
print(f"failed: {result.get('error')} ({result.get('error_code')})")
|
|
54
|
+
raise SystemExit(1)
|
|
55
|
+
|
|
56
|
+
print("generation_id :", result.get("generation_id"))
|
|
57
|
+
print("seed :", result.get("seed"))
|
|
58
|
+
print("credits_charged:", result.get("credits_charged"))
|
|
59
|
+
print("video_url :", result.get("video_url"))
|
|
60
|
+
|
|
61
|
+
client.download(result["video_url"], "out_t2v.mp4")
|
|
62
|
+
print("saved → out_t2v.mp4")
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## 3. Image-to-video, end-to-end
|
|
66
|
+
|
|
67
|
+
Pass `image_path` pointing at a local image; everything else is the same.
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
import os
|
|
71
|
+
|
|
72
|
+
from varya import VaryaClient
|
|
73
|
+
|
|
74
|
+
with VaryaClient(os.environ["VARYA_API_KEY"]) as client:
|
|
75
|
+
result = client.generate_video(
|
|
76
|
+
"a watch floating on the water",
|
|
77
|
+
resolution="480p",
|
|
78
|
+
duration=5,
|
|
79
|
+
image_path="watch3.png",
|
|
80
|
+
on_status=lambda s: print(f"status: {s}"),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
if result.get("status") == "failed":
|
|
84
|
+
raise SystemExit(f"{result.get('error')} ({result.get('error_code')})")
|
|
85
|
+
|
|
86
|
+
client.download(result["video_url"], "out_i2v.mp4")
|
|
87
|
+
print("saved → out_i2v.mp4")
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## 4. Settings reference
|
|
91
|
+
|
|
92
|
+
| Option | Default | Notes |
|
|
93
|
+
| --- | --- | --- |
|
|
94
|
+
| `resolution` | `"480p"` | `"480p"` = 1 credit, `"720p"` = 2 credits |
|
|
95
|
+
| `duration` | `5` | seconds, `1`–`5` only (single clip) |
|
|
96
|
+
| `style` | `"none"` | `none`, `cinematic`, `anime`, `documentary`, `product_ad`, `realistic`, `fantasy`, `cyberpunk`, `vintage_film` |
|
|
97
|
+
| `seed` | random | pass an int to reproduce a run; the seed used is returned on the result |
|
|
98
|
+
| `apply_watermark` | `False` | selects the watermark logo (`True` = brand logo); output is always watermarked |
|
|
99
|
+
| `image_path` | — | switches to image-to-video |
|
|
100
|
+
|
|
101
|
+
Polling options for `generate_video` / `wait`: `poll_interval` (default `2.0`),
|
|
102
|
+
`timeout` (default `600.0`), `on_status`.
|
|
103
|
+
|
|
104
|
+
## 5. Split submit / poll (advanced)
|
|
105
|
+
|
|
106
|
+
If you don't want to block, submit and poll yourself:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
with VaryaClient(os.environ["VARYA_API_KEY"]) as client:
|
|
110
|
+
created = client.submit("a cat surfing a wave", resolution="720p")
|
|
111
|
+
gen_id = created["generation_id"]
|
|
112
|
+
|
|
113
|
+
# …later, or from another process:
|
|
114
|
+
result = client.wait(gen_id, on_status=print)
|
|
115
|
+
print(result["video_url"])
|
|
116
|
+
|
|
117
|
+
# or a single status check (no waiting):
|
|
118
|
+
snapshot = client.status(gen_id)
|
|
119
|
+
print(snapshot["status"])
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## 6. Error handling
|
|
123
|
+
|
|
124
|
+
API/business failures raise `VaryaError` with a machine-readable `.code` and
|
|
125
|
+
HTTP `.status`:
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
from varya import VaryaError
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
client.generate_video("…")
|
|
132
|
+
except VaryaError as exc:
|
|
133
|
+
if exc.code == "INSUFFICIENT_CREDITS":
|
|
134
|
+
... # prompt the user to top up
|
|
135
|
+
elif exc.code == "NSFW_CONTENT_BLOCKED":
|
|
136
|
+
... # adjust the prompt/image
|
|
137
|
+
else:
|
|
138
|
+
raise
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Note: a job that is accepted but ends unsuccessfully comes back as a normal
|
|
142
|
+
result `dict` with `status == "failed"` (read `error` / `error_code`) — not as
|
|
143
|
+
a raised `VaryaError`.
|
|
144
|
+
|
|
145
|
+
## 7. CLI
|
|
146
|
+
|
|
147
|
+
The package also installs a `varya` command:
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
# create and download a video
|
|
151
|
+
varya "a cat surfing a wave" --resolution 720p --output out.mp4
|
|
152
|
+
|
|
153
|
+
# fetch an existing generation (no prompt, nothing re-charged)
|
|
154
|
+
varya --get <generation_id> --output out.mp4
|
|
155
|
+
|
|
156
|
+
varya --help
|
|
157
|
+
```
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "varya-avataar"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python client for the Varya video generation API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Varya" }]
|
|
13
|
+
keywords = ["varya", "video", "generation", "ai", "text-to-video", "api", "sdk"]
|
|
14
|
+
dependencies = ["requests>=2.25"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Topic :: Multimedia :: Video",
|
|
22
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
dev = ["pytest>=7"]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://varya.avataar.ai"
|
|
30
|
+
Documentation = "https://github.com/SoulVisionCreations/varya-python#readme"
|
|
31
|
+
Source = "https://github.com/SoulVisionCreations/varya-python"
|
|
32
|
+
|
|
33
|
+
[project.scripts]
|
|
34
|
+
varya = "varya.cli:main"
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["src/varya"]
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.sdist]
|
|
40
|
+
include = ["src/varya", "README.md", "USAGE.md", "LICENSE", "tests"]
|
|
41
|
+
|
|
42
|
+
[tool.pytest.ini_options]
|
|
43
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Varya — Python client for the Varya video generation API.
|
|
2
|
+
|
|
3
|
+
Public API:
|
|
4
|
+
from varya import VaryaClient, VaryaError, DEFAULT_BASE_URL
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .client import DEFAULT_BASE_URL, VaryaClient, VaryaError
|
|
8
|
+
|
|
9
|
+
__all__ = ["VaryaClient", "VaryaError", "DEFAULT_BASE_URL"]
|
|
10
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Command-line interface: ``varya`` (and ``python -m varya``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from .client import DEFAULT_BASE_URL, VaryaClient, VaryaError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main(argv: list[str] | None = None) -> int:
|
|
13
|
+
parser = argparse.ArgumentParser(
|
|
14
|
+
prog="varya", description="Generate a video with the Varya API."
|
|
15
|
+
)
|
|
16
|
+
parser.add_argument("prompt", nargs="?", help="Text prompt for the video.")
|
|
17
|
+
parser.add_argument(
|
|
18
|
+
"--get",
|
|
19
|
+
dest="get_id",
|
|
20
|
+
default=None,
|
|
21
|
+
help="Fetch/await an existing generation_id instead of creating one "
|
|
22
|
+
"(no prompt needed, nothing is re-charged).",
|
|
23
|
+
)
|
|
24
|
+
parser.add_argument(
|
|
25
|
+
"--key",
|
|
26
|
+
default=os.environ.get("VARYA_API_KEY"),
|
|
27
|
+
help="API key (sk_live_...). Defaults to $VARYA_API_KEY.",
|
|
28
|
+
)
|
|
29
|
+
parser.add_argument("--base-url", default=os.environ.get("VARYA_BASE_URL", DEFAULT_BASE_URL))
|
|
30
|
+
parser.add_argument("--auth-scheme", default="x-api-key", choices=["x-api-key", "bearer"])
|
|
31
|
+
parser.add_argument("--resolution", default="480p", choices=["480p", "720p"])
|
|
32
|
+
parser.add_argument("--duration", type=int, default=5, help="Seconds, 1-5.")
|
|
33
|
+
parser.add_argument("--style", default="none")
|
|
34
|
+
parser.add_argument("--seed", type=int, default=None)
|
|
35
|
+
parser.add_argument("--enhance", action="store_true")
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
"--watermark",
|
|
38
|
+
action="store_true",
|
|
39
|
+
help="Use the primary brand logo (output is always watermarked).",
|
|
40
|
+
)
|
|
41
|
+
parser.add_argument("--image", default=None, help="Path to a reference image (I2V).")
|
|
42
|
+
parser.add_argument("--output", default=None, help="Download the result MP4 to this path.")
|
|
43
|
+
parser.add_argument("--timeout", type=float, default=600.0)
|
|
44
|
+
args = parser.parse_args(argv)
|
|
45
|
+
|
|
46
|
+
if not args.key:
|
|
47
|
+
print("error: no API key. Pass --key or set VARYA_API_KEY.", file=sys.stderr)
|
|
48
|
+
return 2
|
|
49
|
+
if not args.prompt and not args.get_id:
|
|
50
|
+
print("error: provide a prompt, or --get <generation_id>.", file=sys.stderr)
|
|
51
|
+
return 2
|
|
52
|
+
|
|
53
|
+
client = VaryaClient(args.key, base_url=args.base_url, auth_scheme=args.auth_scheme)
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
if args.get_id:
|
|
57
|
+
result = client.wait(
|
|
58
|
+
args.get_id, timeout=args.timeout, on_status=lambda s: print(f"… {s}")
|
|
59
|
+
)
|
|
60
|
+
result.setdefault("generation_id", args.get_id)
|
|
61
|
+
else:
|
|
62
|
+
result = client.generate_video(
|
|
63
|
+
args.prompt,
|
|
64
|
+
resolution=args.resolution,
|
|
65
|
+
duration=args.duration,
|
|
66
|
+
style=args.style,
|
|
67
|
+
seed=args.seed,
|
|
68
|
+
enhance=args.enhance,
|
|
69
|
+
apply_watermark=args.watermark,
|
|
70
|
+
image_path=args.image,
|
|
71
|
+
timeout=args.timeout,
|
|
72
|
+
on_status=lambda s: print(f"… {s}"),
|
|
73
|
+
)
|
|
74
|
+
except VaryaError as exc:
|
|
75
|
+
print(f"error [{exc.code}]: {exc}", file=sys.stderr)
|
|
76
|
+
return 1
|
|
77
|
+
|
|
78
|
+
if result.get("status") == "failed":
|
|
79
|
+
print(f"failed: {result.get('error')} ({result.get('error_code')})", file=sys.stderr)
|
|
80
|
+
return 1
|
|
81
|
+
|
|
82
|
+
video_url = result.get("video_url")
|
|
83
|
+
print(
|
|
84
|
+
f"done — generation_id={result.get('generation_id')} "
|
|
85
|
+
f"seed={result.get('seed')} credits={result.get('credits_charged')}"
|
|
86
|
+
)
|
|
87
|
+
if video_url:
|
|
88
|
+
print(f"video_url: {video_url}")
|
|
89
|
+
|
|
90
|
+
if args.output and video_url:
|
|
91
|
+
client.download(video_url, args.output)
|
|
92
|
+
print(f"saved → {args.output}")
|
|
93
|
+
|
|
94
|
+
return 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if __name__ == "__main__":
|
|
98
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""Core client for the Varya video generation API.
|
|
2
|
+
|
|
3
|
+
The API is asynchronous: you submit a job (`POST /api/generations`) and poll
|
|
4
|
+
(`GET /generations/{id}`) until it is ``completed`` or ``failed``. This module
|
|
5
|
+
wraps that flow with connection reuse and automatic backoff retries on
|
|
6
|
+
idempotent requests so transient network blips don't abort a run.
|
|
7
|
+
|
|
8
|
+
The SDK targets the streamlined single-clip endpoint: no compare / long-form /
|
|
9
|
+
continuation mode, ``fps`` is fixed server-side, and ``duration`` is 1-5s.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import mimetypes
|
|
15
|
+
import os
|
|
16
|
+
import random
|
|
17
|
+
import time
|
|
18
|
+
from typing import Any, Callable, Dict, Optional
|
|
19
|
+
|
|
20
|
+
import requests
|
|
21
|
+
from requests.adapters import HTTPAdapter
|
|
22
|
+
|
|
23
|
+
try: # urllib3 ships with requests; import path is stable across versions
|
|
24
|
+
from urllib3.util.retry import Retry
|
|
25
|
+
except Exception: # pragma: no cover - extremely old urllib3
|
|
26
|
+
Retry = None
|
|
27
|
+
|
|
28
|
+
DEFAULT_BASE_URL = "https://video-backend.avataar.ai"
|
|
29
|
+
|
|
30
|
+
# Matches the web app's seed range: [SEED_MIN, SEED_MAX).
|
|
31
|
+
SEED_MIN = 100
|
|
32
|
+
SEED_MAX = 1_000_000
|
|
33
|
+
|
|
34
|
+
__all__ = ["VaryaClient", "VaryaError", "DEFAULT_BASE_URL"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class VaryaError(RuntimeError):
|
|
38
|
+
"""Raised for API / business errors.
|
|
39
|
+
|
|
40
|
+
Attributes
|
|
41
|
+
----------
|
|
42
|
+
code:
|
|
43
|
+
Machine-readable code from the backend (e.g. ``NSFW_CONTENT_BLOCKED``,
|
|
44
|
+
``INSUFFICIENT_CREDITS``, ``GENERATION_NOT_FOUND``) or a client-side code
|
|
45
|
+
like ``TIMEOUT`` / ``NO_ID``.
|
|
46
|
+
status:
|
|
47
|
+
HTTP status code when the error originated from a response.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, message: str, *, code: str = "UNKNOWN", status: int = 0) -> None:
|
|
51
|
+
super().__init__(message)
|
|
52
|
+
self.code = code
|
|
53
|
+
self.status = status
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class VaryaClient:
|
|
57
|
+
"""Thin, dependency-light wrapper over the Varya ``/api/generations`` pipeline.
|
|
58
|
+
|
|
59
|
+
Parameters
|
|
60
|
+
----------
|
|
61
|
+
api_key:
|
|
62
|
+
A user API key (``sk_live_...``) minted from the Varya web app
|
|
63
|
+
(Account menu -> API key).
|
|
64
|
+
base_url:
|
|
65
|
+
API base URL. Defaults to the public production endpoint.
|
|
66
|
+
auth_scheme:
|
|
67
|
+
How the key is sent. ``"x-api-key"`` (default) sends an ``X-API-Key``
|
|
68
|
+
header; ``"bearer"`` sends ``Authorization: Bearer <key>``.
|
|
69
|
+
connect_timeout / read_timeout:
|
|
70
|
+
Per-request connect and read timeouts in seconds.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
api_key: str,
|
|
76
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
77
|
+
auth_scheme: str = "x-api-key",
|
|
78
|
+
connect_timeout: float = 15.0,
|
|
79
|
+
read_timeout: float = 60.0,
|
|
80
|
+
) -> None:
|
|
81
|
+
if not api_key:
|
|
82
|
+
raise ValueError("api_key is required")
|
|
83
|
+
self.api_key = api_key
|
|
84
|
+
self.base_url = base_url.rstrip("/")
|
|
85
|
+
self.auth_scheme = auth_scheme.lower()
|
|
86
|
+
self.timeout = (connect_timeout, read_timeout)
|
|
87
|
+
|
|
88
|
+
# One keep-alive session reused for every call: avoids opening a fresh
|
|
89
|
+
# TCP/TLS connection per poll and adds backoff retries for transient,
|
|
90
|
+
# idempotent GET failures. POST is intentionally NOT auto-retried so a
|
|
91
|
+
# generation is never accidentally submitted (and charged) twice.
|
|
92
|
+
self._session = requests.Session()
|
|
93
|
+
if Retry is not None:
|
|
94
|
+
retry = Retry(
|
|
95
|
+
total=5,
|
|
96
|
+
connect=5,
|
|
97
|
+
read=3,
|
|
98
|
+
backoff_factor=1.5,
|
|
99
|
+
status_forcelist=(429, 500, 502, 503, 504),
|
|
100
|
+
allowed_methods=frozenset({"GET"}),
|
|
101
|
+
raise_on_status=False,
|
|
102
|
+
)
|
|
103
|
+
adapter = HTTPAdapter(max_retries=retry)
|
|
104
|
+
self._session.mount("https://", adapter)
|
|
105
|
+
self._session.mount("http://", adapter)
|
|
106
|
+
|
|
107
|
+
# -- internals ---------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
def _auth_headers(self) -> Dict[str, str]:
|
|
110
|
+
if self.auth_scheme == "bearer":
|
|
111
|
+
return {"Authorization": f"Bearer {self.api_key}"}
|
|
112
|
+
return {"X-API-Key": self.api_key}
|
|
113
|
+
|
|
114
|
+
def _unwrap(self, resp: requests.Response) -> Dict[str, Any]:
|
|
115
|
+
"""Parse the standard ``{ success, data, error }`` envelope.
|
|
116
|
+
|
|
117
|
+
FastAPI-level auth/transport errors arrive as ``{ "detail": ... }`` and
|
|
118
|
+
are converted to :class:`VaryaError` too.
|
|
119
|
+
"""
|
|
120
|
+
try:
|
|
121
|
+
payload = resp.json()
|
|
122
|
+
except ValueError:
|
|
123
|
+
raise VaryaError(
|
|
124
|
+
f"Non-JSON response (HTTP {resp.status_code}): {resp.text[:200]}",
|
|
125
|
+
status=resp.status_code,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
if isinstance(payload, dict) and "detail" in payload and "success" not in payload:
|
|
129
|
+
detail = payload["detail"]
|
|
130
|
+
msg = detail if isinstance(detail, str) else str(detail)
|
|
131
|
+
raise VaryaError(msg, code="HTTP_ERROR", status=resp.status_code)
|
|
132
|
+
|
|
133
|
+
if not isinstance(payload, dict):
|
|
134
|
+
raise VaryaError("Unexpected response shape", status=resp.status_code)
|
|
135
|
+
|
|
136
|
+
if payload.get("success") is False:
|
|
137
|
+
err = payload.get("error") or {}
|
|
138
|
+
raise VaryaError(
|
|
139
|
+
err.get("message") or "Request failed",
|
|
140
|
+
code=err.get("code") or "ERROR",
|
|
141
|
+
status=resp.status_code,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
return payload.get("data") or {}
|
|
145
|
+
|
|
146
|
+
# -- public API --------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
def submit(
|
|
149
|
+
self,
|
|
150
|
+
prompt: str,
|
|
151
|
+
*,
|
|
152
|
+
resolution: str = "480p",
|
|
153
|
+
duration: int = 5,
|
|
154
|
+
style: str = "none",
|
|
155
|
+
seed: Optional[int] = None,
|
|
156
|
+
enhance: bool = False,
|
|
157
|
+
apply_watermark: bool = False,
|
|
158
|
+
image_path: Optional[str] = None,
|
|
159
|
+
) -> Dict[str, Any]:
|
|
160
|
+
"""``POST /api/generations`` — enqueue a single-clip job.
|
|
161
|
+
|
|
162
|
+
Returns the created job's ``data`` (``generation_id``, ``status``,
|
|
163
|
+
``credits_charged``, ...) plus the ``seed`` that was used. Does not wait
|
|
164
|
+
for completion; use :meth:`wait` or :meth:`generate_video` for that.
|
|
165
|
+
|
|
166
|
+
This is the streamlined SDK endpoint: **single clip only** (no compare,
|
|
167
|
+
long-form, or continuation), ``fps`` is fixed server-side, and
|
|
168
|
+
``duration`` must be **1-5 seconds**. The output is always watermarked;
|
|
169
|
+
``apply_watermark`` only selects which logo is overlaid.
|
|
170
|
+
|
|
171
|
+
``seed`` is always sent: when omitted it is randomized client-side (in
|
|
172
|
+
``[100, 1_000_000)``, matching the web app) and returned so the run is
|
|
173
|
+
reproducible — pass the same ``seed`` to repeat it.
|
|
174
|
+
"""
|
|
175
|
+
if not prompt or not prompt.strip():
|
|
176
|
+
raise ValueError("prompt is required")
|
|
177
|
+
|
|
178
|
+
# Single-clip only; fail fast instead of round-tripping to a server
|
|
179
|
+
# ``LONG_FORM_NOT_SUPPORTED``.
|
|
180
|
+
if not isinstance(duration, int) or not (1 <= duration <= 5):
|
|
181
|
+
raise VaryaError(
|
|
182
|
+
"duration must be an integer between 1 and 5 (single clip only)",
|
|
183
|
+
code="LONG_FORM_NOT_SUPPORTED",
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
if seed is None:
|
|
187
|
+
seed = random.randint(SEED_MIN, SEED_MAX - 1)
|
|
188
|
+
|
|
189
|
+
# The endpoint expects multipart/form-data even with no image attached;
|
|
190
|
+
# requests sends multipart when every field is passed through `files`.
|
|
191
|
+
fields: Dict[str, Any] = {
|
|
192
|
+
"prompt": prompt,
|
|
193
|
+
"resolution": resolution,
|
|
194
|
+
"duration": duration,
|
|
195
|
+
"style": style,
|
|
196
|
+
"seed": seed,
|
|
197
|
+
"enhance": str(enhance).lower(),
|
|
198
|
+
"apply_watermark": str(apply_watermark).lower(),
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
parts = [(k, (None, str(v))) for k, v in fields.items()]
|
|
202
|
+
|
|
203
|
+
opened = None
|
|
204
|
+
if image_path:
|
|
205
|
+
mime = mimetypes.guess_type(image_path)[0] or "application/octet-stream"
|
|
206
|
+
opened = open(image_path, "rb")
|
|
207
|
+
parts.append(("image", (os.path.basename(image_path), opened, mime)))
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
resp = self._session.post(
|
|
211
|
+
f"{self.base_url}/api/generations",
|
|
212
|
+
headers=self._auth_headers(),
|
|
213
|
+
files=parts,
|
|
214
|
+
timeout=self.timeout,
|
|
215
|
+
)
|
|
216
|
+
finally:
|
|
217
|
+
if opened:
|
|
218
|
+
opened.close()
|
|
219
|
+
|
|
220
|
+
data = self._unwrap(resp)
|
|
221
|
+
data.setdefault("seed", seed)
|
|
222
|
+
return data
|
|
223
|
+
|
|
224
|
+
def status(self, generation_id: str) -> Dict[str, Any]:
|
|
225
|
+
"""``GET /generations/{id}`` — current status / result ``data``."""
|
|
226
|
+
resp = self._session.get(
|
|
227
|
+
f"{self.base_url}/generations/{generation_id}",
|
|
228
|
+
headers=self._auth_headers(),
|
|
229
|
+
timeout=self.timeout,
|
|
230
|
+
)
|
|
231
|
+
return self._unwrap(resp)
|
|
232
|
+
|
|
233
|
+
def wait(
|
|
234
|
+
self,
|
|
235
|
+
generation_id: str,
|
|
236
|
+
*,
|
|
237
|
+
poll_interval: float = 2.0,
|
|
238
|
+
timeout: float = 600.0,
|
|
239
|
+
on_status: Optional[Callable[[str], None]] = None,
|
|
240
|
+
) -> Dict[str, Any]:
|
|
241
|
+
"""Poll until the job is ``completed`` / ``failed`` or ``timeout`` elapses.
|
|
242
|
+
|
|
243
|
+
Transient network errors are tolerated: the job keeps running
|
|
244
|
+
server-side, so polling resumes after a short wait rather than raising.
|
|
245
|
+
"""
|
|
246
|
+
start = time.monotonic()
|
|
247
|
+
last = None
|
|
248
|
+
while time.monotonic() - start < timeout:
|
|
249
|
+
try:
|
|
250
|
+
data = self.status(generation_id)
|
|
251
|
+
except requests.exceptions.RequestException as exc:
|
|
252
|
+
if on_status and last != "_reconnecting":
|
|
253
|
+
on_status(f"network hiccup, retrying… ({type(exc).__name__})")
|
|
254
|
+
last = "_reconnecting"
|
|
255
|
+
time.sleep(poll_interval)
|
|
256
|
+
continue
|
|
257
|
+
state = data.get("status")
|
|
258
|
+
if on_status and state != last:
|
|
259
|
+
on_status(state)
|
|
260
|
+
last = state
|
|
261
|
+
if state in ("completed", "failed"):
|
|
262
|
+
return data
|
|
263
|
+
time.sleep(poll_interval)
|
|
264
|
+
raise VaryaError("Timed out waiting for the generation to finish", code="TIMEOUT")
|
|
265
|
+
|
|
266
|
+
def generate_video(
|
|
267
|
+
self,
|
|
268
|
+
prompt: str,
|
|
269
|
+
*,
|
|
270
|
+
poll_interval: float = 2.0,
|
|
271
|
+
timeout: float = 600.0,
|
|
272
|
+
on_status: Optional[Callable[[str], None]] = None,
|
|
273
|
+
**submit_kwargs: Any,
|
|
274
|
+
) -> Dict[str, Any]:
|
|
275
|
+
"""Submit a job and wait for it. Returns the final status ``data``.
|
|
276
|
+
|
|
277
|
+
On success the result has ``video_url``; on failure it has ``error``
|
|
278
|
+
(and an optional ``error_code``).
|
|
279
|
+
"""
|
|
280
|
+
created = self.submit(prompt, **submit_kwargs)
|
|
281
|
+
gen_id = created.get("generation_id")
|
|
282
|
+
if not gen_id:
|
|
283
|
+
raise VaryaError("No generation_id returned from submit", code="NO_ID")
|
|
284
|
+
result = self.wait(
|
|
285
|
+
gen_id,
|
|
286
|
+
poll_interval=poll_interval,
|
|
287
|
+
timeout=timeout,
|
|
288
|
+
on_status=on_status,
|
|
289
|
+
)
|
|
290
|
+
result.setdefault("generation_id", gen_id)
|
|
291
|
+
result.setdefault("credits_charged", created.get("credits_charged"))
|
|
292
|
+
result.setdefault("seed", created.get("seed"))
|
|
293
|
+
return result
|
|
294
|
+
|
|
295
|
+
def download(self, url: str, dest: str, timeout: float = 120.0) -> str:
|
|
296
|
+
"""Stream a finished video URL to a local file. Returns ``dest``."""
|
|
297
|
+
with self._session.get(url, stream=True, timeout=(self.timeout[0], timeout)) as resp:
|
|
298
|
+
resp.raise_for_status()
|
|
299
|
+
with open(dest, "wb") as fh:
|
|
300
|
+
for chunk in resp.iter_content(chunk_size=1 << 16):
|
|
301
|
+
if chunk:
|
|
302
|
+
fh.write(chunk)
|
|
303
|
+
return dest
|
|
304
|
+
|
|
305
|
+
def close(self) -> None:
|
|
306
|
+
"""Close the underlying HTTP session."""
|
|
307
|
+
self._session.close()
|
|
308
|
+
|
|
309
|
+
def __enter__(self) -> "VaryaClient":
|
|
310
|
+
return self
|
|
311
|
+
|
|
312
|
+
def __exit__(self, *exc: Any) -> None:
|
|
313
|
+
self.close()
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Unit tests for the Varya Python client.
|
|
2
|
+
|
|
3
|
+
No network: the HTTP session is replaced with a fake that returns canned
|
|
4
|
+
responses, so these run fast and offline.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from unittest.mock import MagicMock
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
import requests
|
|
11
|
+
|
|
12
|
+
from varya import VaryaClient, VaryaError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FakeResponse:
|
|
16
|
+
def __init__(self, payload, status_code=200, text=""):
|
|
17
|
+
self._payload = payload
|
|
18
|
+
self.status_code = status_code
|
|
19
|
+
self.text = text
|
|
20
|
+
|
|
21
|
+
def json(self):
|
|
22
|
+
if self._payload is _NO_JSON:
|
|
23
|
+
raise ValueError("no json")
|
|
24
|
+
return self._payload
|
|
25
|
+
|
|
26
|
+
def raise_for_status(self):
|
|
27
|
+
if self.status_code >= 400:
|
|
28
|
+
raise requests.exceptions.HTTPError(response=self)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_NO_JSON = object()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def make_client():
|
|
35
|
+
client = VaryaClient(api_key="sk_live_test")
|
|
36
|
+
client._session = MagicMock()
|
|
37
|
+
return client
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def envelope(data=None, success=True, error=None):
|
|
41
|
+
return {"success": success, "data": data, "error": error}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_requires_api_key():
|
|
45
|
+
with pytest.raises(ValueError):
|
|
46
|
+
VaryaClient(api_key="")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_auth_header_default_is_x_api_key():
|
|
50
|
+
client = VaryaClient(api_key="sk_live_abc")
|
|
51
|
+
assert client._auth_headers() == {"X-API-Key": "sk_live_abc"}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_auth_header_bearer():
|
|
55
|
+
client = VaryaClient(api_key="sk_live_abc", auth_scheme="bearer")
|
|
56
|
+
assert client._auth_headers() == {"Authorization": "Bearer sk_live_abc"}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_submit_sends_multipart_fields_and_returns_data():
|
|
60
|
+
client = make_client()
|
|
61
|
+
client._session.post.return_value = FakeResponse(
|
|
62
|
+
envelope({"generation_id": "gen_1", "status": "queued", "credits_charged": 1})
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
data = client.submit("a cat", resolution="720p", duration=5)
|
|
66
|
+
|
|
67
|
+
assert data["generation_id"] == "gen_1"
|
|
68
|
+
# Verify it posted to /api/generations as multipart (files=...).
|
|
69
|
+
args, kwargs = client._session.post.call_args
|
|
70
|
+
assert args[0].endswith("/api/generations")
|
|
71
|
+
files = kwargs["files"]
|
|
72
|
+
sent = {name: part[1] for name, part in files}
|
|
73
|
+
assert sent["prompt"] == "a cat"
|
|
74
|
+
assert sent["resolution"] == "720p"
|
|
75
|
+
# Single-clip endpoint: these are no longer sent.
|
|
76
|
+
assert "mode" not in sent
|
|
77
|
+
assert "fps" not in sent
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_submit_sends_random_seed_and_returns_it():
|
|
81
|
+
client = make_client()
|
|
82
|
+
client._session.post.return_value = FakeResponse(
|
|
83
|
+
envelope({"generation_id": "gen_s", "status": "queued"})
|
|
84
|
+
)
|
|
85
|
+
data = client.submit("a cat")
|
|
86
|
+
_, kwargs = client._session.post.call_args
|
|
87
|
+
sent = {name: part[1] for name, part in kwargs["files"]}
|
|
88
|
+
assert "seed" in sent # always sent, even when caller omits it
|
|
89
|
+
assert 100 <= int(sent["seed"]) < 1_000_000
|
|
90
|
+
assert int(sent["seed"]) == data["seed"] # surfaced back to the caller
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_submit_honors_provided_seed():
|
|
94
|
+
client = make_client()
|
|
95
|
+
client._session.post.return_value = FakeResponse(
|
|
96
|
+
envelope({"generation_id": "gen_s", "status": "queued"})
|
|
97
|
+
)
|
|
98
|
+
data = client.submit("a cat", seed=12345)
|
|
99
|
+
_, kwargs = client._session.post.call_args
|
|
100
|
+
sent = {name: part[1] for name, part in kwargs["files"]}
|
|
101
|
+
assert sent["seed"] == "12345"
|
|
102
|
+
assert data["seed"] == 12345
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_submit_rejects_empty_prompt():
|
|
106
|
+
client = make_client()
|
|
107
|
+
with pytest.raises(ValueError):
|
|
108
|
+
client.submit(" ")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_submit_rejects_out_of_range_duration():
|
|
112
|
+
client = make_client()
|
|
113
|
+
with pytest.raises(VaryaError) as ei:
|
|
114
|
+
client.submit("a cat", duration=8)
|
|
115
|
+
assert ei.value.code == "LONG_FORM_NOT_SUPPORTED"
|
|
116
|
+
# Nothing should have been sent to the server.
|
|
117
|
+
client._session.post.assert_not_called()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_business_error_raises_varya_error_with_code():
|
|
121
|
+
client = make_client()
|
|
122
|
+
client._session.post.return_value = FakeResponse(
|
|
123
|
+
envelope(success=False, error={"code": "INSUFFICIENT_CREDITS", "message": "need 2, have 0"})
|
|
124
|
+
)
|
|
125
|
+
with pytest.raises(VaryaError) as ei:
|
|
126
|
+
client.submit("a cat")
|
|
127
|
+
assert ei.value.code == "INSUFFICIENT_CREDITS"
|
|
128
|
+
assert "need 2" in str(ei.value)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_detail_error_raises_varya_error():
|
|
132
|
+
client = make_client()
|
|
133
|
+
client._session.get.return_value = FakeResponse(
|
|
134
|
+
{"detail": "This endpoint requires a Firebase ID token, not an API key."},
|
|
135
|
+
status_code=401,
|
|
136
|
+
)
|
|
137
|
+
with pytest.raises(VaryaError) as ei:
|
|
138
|
+
client.status("gen_x")
|
|
139
|
+
assert ei.value.status == 401
|
|
140
|
+
assert ei.value.code == "HTTP_ERROR"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_generate_video_submits_then_polls_to_completion():
|
|
144
|
+
client = make_client()
|
|
145
|
+
client._session.post.return_value = FakeResponse(
|
|
146
|
+
envelope({"generation_id": "gen_2", "status": "queued", "credits_charged": 2})
|
|
147
|
+
)
|
|
148
|
+
client._session.get.side_effect = [
|
|
149
|
+
FakeResponse(envelope({"status": "processing"})),
|
|
150
|
+
FakeResponse(envelope({"status": "completed", "video_url": "https://cdn/v.mp4"})),
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
seen = []
|
|
154
|
+
result = client.generate_video(
|
|
155
|
+
"a cat", resolution="720p", poll_interval=0, on_status=seen.append
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
assert result["status"] == "completed"
|
|
159
|
+
assert result["video_url"] == "https://cdn/v.mp4"
|
|
160
|
+
assert result["generation_id"] == "gen_2"
|
|
161
|
+
assert result["credits_charged"] == 2
|
|
162
|
+
assert "completed" in seen
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_wait_tolerates_transient_network_errors():
|
|
166
|
+
client = make_client()
|
|
167
|
+
client._session.get.side_effect = [
|
|
168
|
+
requests.exceptions.ConnectTimeout("boom"),
|
|
169
|
+
FakeResponse(envelope({"status": "completed", "video_url": "https://cdn/v.mp4"})),
|
|
170
|
+
]
|
|
171
|
+
result = client.wait("gen_3", poll_interval=0, timeout=5)
|
|
172
|
+
assert result["status"] == "completed"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_wait_times_out():
|
|
176
|
+
client = make_client()
|
|
177
|
+
client._session.get.return_value = FakeResponse(envelope({"status": "processing"}))
|
|
178
|
+
with pytest.raises(VaryaError) as ei:
|
|
179
|
+
client.wait("gen_4", poll_interval=0, timeout=0.05)
|
|
180
|
+
assert ei.value.code == "TIMEOUT"
|