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.
@@ -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"