movoa 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.
- movoa-0.1.0/LICENSE +21 -0
- movoa-0.1.0/PKG-INFO +106 -0
- movoa-0.1.0/README.md +84 -0
- movoa-0.1.0/pyproject.toml +30 -0
- movoa-0.1.0/setup.cfg +4 -0
- movoa-0.1.0/src/movoa/__init__.py +305 -0
- movoa-0.1.0/src/movoa.egg-info/PKG-INFO +106 -0
- movoa-0.1.0/src/movoa.egg-info/SOURCES.txt +9 -0
- movoa-0.1.0/src/movoa.egg-info/dependency_links.txt +1 -0
- movoa-0.1.0/src/movoa.egg-info/top_level.txt +1 -0
- movoa-0.1.0/tests/test_movoa.py +135 -0
movoa-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Movoa (https://movoa.ai)
|
|
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.
|
movoa-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: movoa
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Unlimited AI video generator with sound: on paid plans the tenth take of a shot costs what the first did, and your first clip is free to make right now. Official Python SDK for Movoa (movoa.ai): text to video and photo to video with sound from your own code.
|
|
5
|
+
Author: Movoa (https://movoa.ai)
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://movoa.ai/?utm_source=pypi&utm_medium=package
|
|
8
|
+
Project-URL: Text to video, https://movoa.ai/video/text-to-video
|
|
9
|
+
Project-URL: API keys, https://movoa.ai/settings/apikeys
|
|
10
|
+
Project-URL: Pricing, https://movoa.ai/pricing
|
|
11
|
+
Project-URL: Source, https://github.com/hanshs474/movoa-py
|
|
12
|
+
Keywords: ai,video-generation,ai-video-generator,text-to-video,image-to-video,photo-to-video,ai-video-with-audio,minimax-h3,seedance-2-5,veo-3,movoa
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: Multimedia :: Video
|
|
18
|
+
Requires-Python: >=3.8
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# movoa
|
|
24
|
+
|
|
25
|
+
Unlimited AI video generator with sound: on paid plans the tenth take of a shot costs what the first did, and your first clip is free to make right now. This is the official Python SDK for [Movoa](https://movoa.ai/?utm_source=pypi&utm_medium=package): one API key turns a prompt into [text to video](https://movoa.ai/video/text-to-video), or a still into [photo to video](https://movoa.ai/video/photo-to-video), and every clip comes back as [AI video with audio](https://movoa.ai/video/ai-video-with-audio).
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install movoa
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from movoa import generate_video
|
|
33
|
+
|
|
34
|
+
clip = generate_video("a paper boat drifting down a rain gutter, low angle, slow push-in")
|
|
35
|
+
print(clip) # URL of the finished MP4, sound included
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Standard library only, no dependencies. `generate_video` submits the run, waits for it and returns the URL of the finished file.
|
|
39
|
+
|
|
40
|
+
## API key
|
|
41
|
+
|
|
42
|
+
Sign in on [movoa.ai](https://movoa.ai/?utm_source=pypi&utm_medium=package), create a key at [movoa.ai/settings/apikeys](https://movoa.ai/settings/apikeys), then pass it as `api_key=` or export it:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
export MOVOA_API_KEY=sk-...
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Runs cost credits on your Movoa account, at the prices on [movoa.ai/pricing](https://movoa.ai/pricing).
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from movoa import credits
|
|
52
|
+
print(credits()) # remaining credits on the account
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Text to video and photo to video
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from movoa import generate_video
|
|
59
|
+
|
|
60
|
+
# text to video, vertical, 10 seconds
|
|
61
|
+
generate_video("a fox trotting through fresh snow at dawn, tracking shot",
|
|
62
|
+
duration=10, aspect_ratio="9:16")
|
|
63
|
+
|
|
64
|
+
# photo to video: the photo becomes the first frame
|
|
65
|
+
generate_video("the camera slowly orbits the statue", image_url="https://example.com/statue.jpg")
|
|
66
|
+
|
|
67
|
+
# another model
|
|
68
|
+
generate_video("a street drummer in the rain, close-up", model="seedance-2-5", duration=10, resolution="720p")
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
| model | options |
|
|
72
|
+
|---|---|
|
|
73
|
+
| `minimax-h3` (default) | `duration` 4 / 5 / 6 / 8 / 10 / 15 · `resolution` 480p / 768p · `aspect_ratio` 16:9 / 9:16 / 1:1 |
|
|
74
|
+
| `veo-3-1-fast` | `duration` 8 · `resolution` 720p · `aspect_ratio` 16:9 / 9:16 (photo to video: 16:9) |
|
|
75
|
+
| `seedance-2-5` | `duration` 5 / 10 / 15 / 20 / 25 / 30 · `resolution` 480p / 720p · `aspect_ratio` 16:9 / 9:16 / 1:1 |
|
|
76
|
+
| `seedance-2`, `seedance-2-fast`, `seedance-2-mini` | `duration` 4 / 6 / 8 / 10 · `resolution` 480p / 720p · `aspect_ratio` |
|
|
77
|
+
|
|
78
|
+
Any extra keyword argument is sent in `options` as it is. Every run asks for sound. On the site: [Seedance 2.5](https://movoa.ai/video/seedance-2-5) and [Veo 3](https://movoa.ai/video/google-veo-3).
|
|
79
|
+
|
|
80
|
+
## Submit now, collect later
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from movoa import submit, status, wait
|
|
84
|
+
|
|
85
|
+
task_id = submit("a lighthouse in a storm, waves crashing", "minimax-h3", duration=6)
|
|
86
|
+
print(status(task_id)["status"]) # pending | processing | success | failed | canceled
|
|
87
|
+
url = wait(task_id) # polls every 10 seconds, 30 minutes by default
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
While a run waits in line, `status()` returns `queued: True` and `queueEta` in seconds.
|
|
91
|
+
|
|
92
|
+
## Errors worth catching
|
|
93
|
+
|
|
94
|
+
Each failure is its own subclass of `MovoaError`, so you can branch on what to do next:
|
|
95
|
+
|
|
96
|
+
- `AuthError`: no key, or the key is wrong or deleted; create one at [movoa.ai/settings/apikeys](https://movoa.ai/settings/apikeys)
|
|
97
|
+
- `QuotaError`: the account does not have enough credits for this run
|
|
98
|
+
- `PlanError`: that model or quality needs a plan on the account
|
|
99
|
+
- `BusyError`: too many runs on this account are generating at once
|
|
100
|
+
- `RejectedError`: the content filter refused the prompt, or a cooldown is active; reword it
|
|
101
|
+
- `GenerationError`: the run was accepted but failed or was canceled; `.error_message` says why
|
|
102
|
+
- `TimeoutError`: the wait ran out; `.task_id` lets you check the run later with `status()`
|
|
103
|
+
|
|
104
|
+
Any other refusal raises `MovoaError` with the server's message.
|
|
105
|
+
|
|
106
|
+
MIT licensed. Made by [Movoa](https://movoa.ai/?utm_source=pypi&utm_medium=package).
|
movoa-0.1.0/README.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# movoa
|
|
2
|
+
|
|
3
|
+
Unlimited AI video generator with sound: on paid plans the tenth take of a shot costs what the first did, and your first clip is free to make right now. This is the official Python SDK for [Movoa](https://movoa.ai/?utm_source=pypi&utm_medium=package): one API key turns a prompt into [text to video](https://movoa.ai/video/text-to-video), or a still into [photo to video](https://movoa.ai/video/photo-to-video), and every clip comes back as [AI video with audio](https://movoa.ai/video/ai-video-with-audio).
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install movoa
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from movoa import generate_video
|
|
11
|
+
|
|
12
|
+
clip = generate_video("a paper boat drifting down a rain gutter, low angle, slow push-in")
|
|
13
|
+
print(clip) # URL of the finished MP4, sound included
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Standard library only, no dependencies. `generate_video` submits the run, waits for it and returns the URL of the finished file.
|
|
17
|
+
|
|
18
|
+
## API key
|
|
19
|
+
|
|
20
|
+
Sign in on [movoa.ai](https://movoa.ai/?utm_source=pypi&utm_medium=package), create a key at [movoa.ai/settings/apikeys](https://movoa.ai/settings/apikeys), then pass it as `api_key=` or export it:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
export MOVOA_API_KEY=sk-...
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Runs cost credits on your Movoa account, at the prices on [movoa.ai/pricing](https://movoa.ai/pricing).
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from movoa import credits
|
|
30
|
+
print(credits()) # remaining credits on the account
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Text to video and photo to video
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from movoa import generate_video
|
|
37
|
+
|
|
38
|
+
# text to video, vertical, 10 seconds
|
|
39
|
+
generate_video("a fox trotting through fresh snow at dawn, tracking shot",
|
|
40
|
+
duration=10, aspect_ratio="9:16")
|
|
41
|
+
|
|
42
|
+
# photo to video: the photo becomes the first frame
|
|
43
|
+
generate_video("the camera slowly orbits the statue", image_url="https://example.com/statue.jpg")
|
|
44
|
+
|
|
45
|
+
# another model
|
|
46
|
+
generate_video("a street drummer in the rain, close-up", model="seedance-2-5", duration=10, resolution="720p")
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
| model | options |
|
|
50
|
+
|---|---|
|
|
51
|
+
| `minimax-h3` (default) | `duration` 4 / 5 / 6 / 8 / 10 / 15 · `resolution` 480p / 768p · `aspect_ratio` 16:9 / 9:16 / 1:1 |
|
|
52
|
+
| `veo-3-1-fast` | `duration` 8 · `resolution` 720p · `aspect_ratio` 16:9 / 9:16 (photo to video: 16:9) |
|
|
53
|
+
| `seedance-2-5` | `duration` 5 / 10 / 15 / 20 / 25 / 30 · `resolution` 480p / 720p · `aspect_ratio` 16:9 / 9:16 / 1:1 |
|
|
54
|
+
| `seedance-2`, `seedance-2-fast`, `seedance-2-mini` | `duration` 4 / 6 / 8 / 10 · `resolution` 480p / 720p · `aspect_ratio` |
|
|
55
|
+
|
|
56
|
+
Any extra keyword argument is sent in `options` as it is. Every run asks for sound. On the site: [Seedance 2.5](https://movoa.ai/video/seedance-2-5) and [Veo 3](https://movoa.ai/video/google-veo-3).
|
|
57
|
+
|
|
58
|
+
## Submit now, collect later
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from movoa import submit, status, wait
|
|
62
|
+
|
|
63
|
+
task_id = submit("a lighthouse in a storm, waves crashing", "minimax-h3", duration=6)
|
|
64
|
+
print(status(task_id)["status"]) # pending | processing | success | failed | canceled
|
|
65
|
+
url = wait(task_id) # polls every 10 seconds, 30 minutes by default
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
While a run waits in line, `status()` returns `queued: True` and `queueEta` in seconds.
|
|
69
|
+
|
|
70
|
+
## Errors worth catching
|
|
71
|
+
|
|
72
|
+
Each failure is its own subclass of `MovoaError`, so you can branch on what to do next:
|
|
73
|
+
|
|
74
|
+
- `AuthError`: no key, or the key is wrong or deleted; create one at [movoa.ai/settings/apikeys](https://movoa.ai/settings/apikeys)
|
|
75
|
+
- `QuotaError`: the account does not have enough credits for this run
|
|
76
|
+
- `PlanError`: that model or quality needs a plan on the account
|
|
77
|
+
- `BusyError`: too many runs on this account are generating at once
|
|
78
|
+
- `RejectedError`: the content filter refused the prompt, or a cooldown is active; reword it
|
|
79
|
+
- `GenerationError`: the run was accepted but failed or was canceled; `.error_message` says why
|
|
80
|
+
- `TimeoutError`: the wait ran out; `.task_id` lets you check the run later with `status()`
|
|
81
|
+
|
|
82
|
+
Any other refusal raises `MovoaError` with the server's message.
|
|
83
|
+
|
|
84
|
+
MIT licensed. Made by [Movoa](https://movoa.ai/?utm_source=pypi&utm_medium=package).
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "movoa"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Unlimited AI video generator with sound: on paid plans the tenth take of a shot costs what the first did, and your first clip is free to make right now. Official Python SDK for Movoa (movoa.ai): text to video and photo to video with sound from your own code."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Movoa (https://movoa.ai)" }]
|
|
13
|
+
keywords = ["ai", "video-generation", "ai-video-generator", "text-to-video", "image-to-video", "photo-to-video", "ai-video-with-audio", "minimax-h3", "seedance-2-5", "veo-3", "movoa"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Multimedia :: Video",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://movoa.ai/?utm_source=pypi&utm_medium=package"
|
|
24
|
+
"Text to video" = "https://movoa.ai/video/text-to-video"
|
|
25
|
+
"API keys" = "https://movoa.ai/settings/apikeys"
|
|
26
|
+
Pricing = "https://movoa.ai/pricing"
|
|
27
|
+
Source = "https://github.com/hanshs474/movoa-py"
|
|
28
|
+
|
|
29
|
+
[tool.setuptools.packages.find]
|
|
30
|
+
where = ["src"]
|
movoa-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""Official Python SDK for Movoa (https://movoa.ai), the AI video generator with sound.
|
|
2
|
+
|
|
3
|
+
from movoa import generate_video
|
|
4
|
+
clip = generate_video("a paper boat drifting down a rain gutter, low angle")
|
|
5
|
+
still = generate_video("the camera slowly orbits the statue",
|
|
6
|
+
image_url="https://example.com/statue.jpg")
|
|
7
|
+
|
|
8
|
+
Create a key at https://movoa.ai/settings/apikeys and pass it as ``api_key=``
|
|
9
|
+
or set ``MOVOA_API_KEY``. Runs cost credits on your Movoa account, at the
|
|
10
|
+
prices on https://movoa.ai/pricing.
|
|
11
|
+
|
|
12
|
+
Standard library only.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import time
|
|
20
|
+
import urllib.error
|
|
21
|
+
import urllib.request
|
|
22
|
+
from typing import Any, Callable, Dict, Optional, Tuple
|
|
23
|
+
|
|
24
|
+
__version__ = "0.1.0"
|
|
25
|
+
__all__ = [
|
|
26
|
+
"generate_video",
|
|
27
|
+
"submit",
|
|
28
|
+
"status",
|
|
29
|
+
"wait",
|
|
30
|
+
"credits",
|
|
31
|
+
"MovoaError",
|
|
32
|
+
"AuthError",
|
|
33
|
+
"QuotaError",
|
|
34
|
+
"PlanError",
|
|
35
|
+
"BusyError",
|
|
36
|
+
"RejectedError",
|
|
37
|
+
"GenerationError",
|
|
38
|
+
"TimeoutError",
|
|
39
|
+
"MODELS",
|
|
40
|
+
"DEFAULT_MODEL",
|
|
41
|
+
"BASE_URL",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
BASE_URL = "https://movoa.ai"
|
|
45
|
+
KEYS_URL = BASE_URL + "/settings/apikeys"
|
|
46
|
+
|
|
47
|
+
#: Model ids. ``minimax-h3`` is the default and the cheapest.
|
|
48
|
+
MODELS = (
|
|
49
|
+
"minimax-h3",
|
|
50
|
+
"veo-3-1-fast",
|
|
51
|
+
"seedance-2-5",
|
|
52
|
+
"seedance-2",
|
|
53
|
+
"seedance-2-fast",
|
|
54
|
+
"seedance-2-mini",
|
|
55
|
+
)
|
|
56
|
+
DEFAULT_MODEL = "minimax-h3"
|
|
57
|
+
|
|
58
|
+
POLL_EVERY = 10.0
|
|
59
|
+
TIMEOUT = 30 * 60.0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class MovoaError(RuntimeError):
|
|
63
|
+
"""Base class. Catch a subclass to decide what to do next.
|
|
64
|
+
|
|
65
|
+
``status`` is the HTTP status, ``code`` the envelope code (0, -1, -2, -3),
|
|
66
|
+
``reason`` the ``data.reason`` the service sent, if any.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
message: str,
|
|
72
|
+
status: Optional[int] = None,
|
|
73
|
+
code: Optional[int] = None,
|
|
74
|
+
reason: Optional[str] = None,
|
|
75
|
+
task_id: Optional[str] = None,
|
|
76
|
+
):
|
|
77
|
+
super().__init__(message)
|
|
78
|
+
self.status = status
|
|
79
|
+
self.code = code
|
|
80
|
+
self.reason = reason
|
|
81
|
+
self.task_id = task_id
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class AuthError(MovoaError):
|
|
85
|
+
"""No API key, or the key is invalid or deleted. Create one at /settings/apikeys."""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class QuotaError(MovoaError):
|
|
89
|
+
"""The account does not have enough credits for this run."""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class PlanError(MovoaError):
|
|
93
|
+
"""This model or quality needs a plan, or the account has never bought credits."""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class BusyError(MovoaError):
|
|
97
|
+
"""Too many runs on this account are generating at once. Wait for one to finish."""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class RejectedError(MovoaError):
|
|
101
|
+
"""The content filter refused the prompt (code -2) or a cooldown is active (code -3). Reword it."""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class GenerationError(MovoaError):
|
|
105
|
+
"""The run was accepted but ended as failed or canceled.
|
|
106
|
+
|
|
107
|
+
``error_message`` and ``failure_kind`` carry what the service reported.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
def __init__(self, message: str, task_id: str, error_message: Optional[str] = None, failure_kind: Optional[str] = None):
|
|
111
|
+
super().__init__(message, task_id=task_id)
|
|
112
|
+
self.error_message = error_message
|
|
113
|
+
self.failure_kind = failure_kind
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class TimeoutError(MovoaError): # noqa: A001 - mirrors the SDK error set of every language
|
|
117
|
+
"""The wait ran out. The run may still finish; ``task_id`` lets you check it later."""
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
#: (method, url, body, headers) -> (http_status, raw_body_bytes). Raise OSError on network failure.
|
|
121
|
+
Transport = Callable[[str, str, Optional[dict], dict], Tuple[int, bytes]]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _http(method: str, url: str, body: Optional[dict], headers: dict) -> Tuple[int, bytes]:
|
|
125
|
+
data = json.dumps(body).encode() if body is not None else b""
|
|
126
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
127
|
+
try:
|
|
128
|
+
with urllib.request.urlopen(req, timeout=60) as r:
|
|
129
|
+
return r.status, r.read()
|
|
130
|
+
except urllib.error.HTTPError as e:
|
|
131
|
+
return e.code, e.read() or b""
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _base(base_url: Optional[str]) -> str:
|
|
135
|
+
return (base_url or os.environ.get("MOVOA_BASE_URL") or BASE_URL).rstrip("/")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _key(api_key: Optional[str]) -> str:
|
|
139
|
+
key = (api_key or os.environ.get("MOVOA_API_KEY") or "").strip()
|
|
140
|
+
if not key:
|
|
141
|
+
raise AuthError(f"No API key: pass api_key= or set MOVOA_API_KEY. Create a key at {KEYS_URL}")
|
|
142
|
+
return key
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _raise_for(status_code: int, env: dict) -> None:
|
|
146
|
+
"""Map a refusal to the subclass that says what to do next.
|
|
147
|
+
|
|
148
|
+
movoa.ai answers almost every refusal as HTTP 200 with a non-zero ``code``
|
|
149
|
+
and a ``data.reason``, so the status code alone reads a refusal as success.
|
|
150
|
+
"""
|
|
151
|
+
code = env.get("code") if isinstance(env.get("code"), int) else None
|
|
152
|
+
data = env.get("data") if isinstance(env.get("data"), dict) else {}
|
|
153
|
+
reason = data.get("reason") if isinstance(data.get("reason"), str) else None
|
|
154
|
+
msg = str(env.get("message") or f"request failed (HTTP {status_code})")
|
|
155
|
+
kw = dict(status=status_code, code=code, reason=reason)
|
|
156
|
+
if status_code == 401:
|
|
157
|
+
raise AuthError(f"{msg} (create a key at {KEYS_URL})" if KEYS_URL not in msg else msg, **kw)
|
|
158
|
+
if code in (-2, -3):
|
|
159
|
+
raise RejectedError(msg, **kw)
|
|
160
|
+
if reason == "insufficient_credits":
|
|
161
|
+
raise QuotaError(msg, **kw)
|
|
162
|
+
if reason == "plan_required":
|
|
163
|
+
raise PlanError(msg, **kw)
|
|
164
|
+
if reason == "concurrency":
|
|
165
|
+
raise BusyError(msg, **kw)
|
|
166
|
+
if "no auth" in msg.lower():
|
|
167
|
+
raise AuthError(f"{msg}. Create a key at {KEYS_URL}", **kw)
|
|
168
|
+
raise MovoaError(msg, **kw)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _call(path: str, body: Optional[dict], api_key: Optional[str], base_url: Optional[str], transport: Optional[Transport]) -> dict:
|
|
172
|
+
headers = {
|
|
173
|
+
"Authorization": f"Bearer {_key(api_key)}",
|
|
174
|
+
"Content-Type": "application/json",
|
|
175
|
+
"User-Agent": f"movoa-python/{__version__}",
|
|
176
|
+
}
|
|
177
|
+
st, raw = (transport or _http)("POST", _base(base_url) + path, body, headers)
|
|
178
|
+
try:
|
|
179
|
+
env = json.loads(raw) if raw else {}
|
|
180
|
+
except ValueError:
|
|
181
|
+
env = None
|
|
182
|
+
if st == 401:
|
|
183
|
+
_raise_for(401, env if isinstance(env, dict) else {})
|
|
184
|
+
if not isinstance(env, dict):
|
|
185
|
+
raise MovoaError(f"movoa.ai returned a non-JSON response (HTTP {st})", status=st)
|
|
186
|
+
if st < 400 and env.get("code") == 0:
|
|
187
|
+
return env
|
|
188
|
+
_raise_for(st, env)
|
|
189
|
+
raise AssertionError("unreachable")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def submit(
|
|
193
|
+
prompt: str,
|
|
194
|
+
model: str = DEFAULT_MODEL,
|
|
195
|
+
image_url: Optional[str] = None,
|
|
196
|
+
*,
|
|
197
|
+
api_key: Optional[str] = None,
|
|
198
|
+
base_url: Optional[str] = None,
|
|
199
|
+
transport: Optional[Transport] = None,
|
|
200
|
+
**options: Any,
|
|
201
|
+
) -> str:
|
|
202
|
+
"""Start one run and return its task id without waiting.
|
|
203
|
+
|
|
204
|
+
``image_url`` (a public https image) makes it image-to-video, with the
|
|
205
|
+
photo as the first frame. Extra keyword arguments go into ``options`` as
|
|
206
|
+
they are, e.g. ``duration=6, resolution="768p", aspect_ratio="9:16"``.
|
|
207
|
+
"""
|
|
208
|
+
if not str(prompt or "").strip():
|
|
209
|
+
raise ValueError("prompt is required")
|
|
210
|
+
opts: Dict[str, Any] = {"sound": True}
|
|
211
|
+
opts.update({k: v for k, v in options.items() if v is not None})
|
|
212
|
+
if image_url:
|
|
213
|
+
opts["image_input"] = [image_url]
|
|
214
|
+
body = {
|
|
215
|
+
# The server routes by model; this only has to be non-empty.
|
|
216
|
+
"provider": "auto",
|
|
217
|
+
"mediaType": "video",
|
|
218
|
+
"model": model,
|
|
219
|
+
"scene": "image-to-video" if image_url else "text-to-video",
|
|
220
|
+
"prompt": prompt,
|
|
221
|
+
"options": opts,
|
|
222
|
+
}
|
|
223
|
+
env = _call("/api/ai/generate", body, api_key, base_url, transport)
|
|
224
|
+
task_id = (env.get("data") or {}).get("id")
|
|
225
|
+
if not task_id:
|
|
226
|
+
raise MovoaError("movoa.ai accepted the run but returned no id")
|
|
227
|
+
return str(task_id)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def status(
|
|
231
|
+
task_id: str,
|
|
232
|
+
*,
|
|
233
|
+
api_key: Optional[str] = None,
|
|
234
|
+
base_url: Optional[str] = None,
|
|
235
|
+
transport: Optional[Transport] = None,
|
|
236
|
+
) -> Dict[str, Any]:
|
|
237
|
+
"""Return the run record. ``status`` is pending, processing, success, failed
|
|
238
|
+
or canceled; ``videos`` holds the result URLs on success; ``queued`` and
|
|
239
|
+
``queueEta`` (seconds) are set while it waits in line."""
|
|
240
|
+
env = _call("/api/ai/query", {"taskId": task_id}, api_key, base_url, transport)
|
|
241
|
+
data = env.get("data")
|
|
242
|
+
return data if isinstance(data, dict) else {}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def wait(
|
|
246
|
+
task_id: str,
|
|
247
|
+
*,
|
|
248
|
+
api_key: Optional[str] = None,
|
|
249
|
+
timeout: float = TIMEOUT,
|
|
250
|
+
poll_every: float = POLL_EVERY,
|
|
251
|
+
base_url: Optional[str] = None,
|
|
252
|
+
transport: Optional[Transport] = None,
|
|
253
|
+
) -> str:
|
|
254
|
+
"""Poll a run every ``poll_every`` seconds until it finishes and return the video URL."""
|
|
255
|
+
deadline = time.monotonic() + timeout
|
|
256
|
+
while True:
|
|
257
|
+
try:
|
|
258
|
+
data = status(task_id, api_key=api_key, base_url=base_url, transport=transport)
|
|
259
|
+
except OSError:
|
|
260
|
+
data = {} # a dropped poll is not a failed generation
|
|
261
|
+
state = str(data.get("status") or "").lower()
|
|
262
|
+
if state == "success":
|
|
263
|
+
videos = data.get("videos") or []
|
|
264
|
+
if not videos:
|
|
265
|
+
raise GenerationError(f"run {task_id} finished with no video", task_id)
|
|
266
|
+
return str(videos[0])
|
|
267
|
+
if state in ("failed", "canceled"):
|
|
268
|
+
em = data.get("errorMessage")
|
|
269
|
+
raise GenerationError(
|
|
270
|
+
f"run {task_id} {state}" + (f": {em}" if em else ""), task_id, em, data.get("failureKind")
|
|
271
|
+
)
|
|
272
|
+
if time.monotonic() + poll_every > deadline:
|
|
273
|
+
raise TimeoutError(
|
|
274
|
+
f"run {task_id} is still generating after {timeout:g}s; check it later with status()",
|
|
275
|
+
task_id=task_id,
|
|
276
|
+
)
|
|
277
|
+
time.sleep(poll_every)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def generate_video(
|
|
281
|
+
prompt: str,
|
|
282
|
+
model: str = DEFAULT_MODEL,
|
|
283
|
+
image_url: Optional[str] = None,
|
|
284
|
+
*,
|
|
285
|
+
api_key: Optional[str] = None,
|
|
286
|
+
timeout: float = TIMEOUT,
|
|
287
|
+
poll_every: float = POLL_EVERY,
|
|
288
|
+
base_url: Optional[str] = None,
|
|
289
|
+
transport: Optional[Transport] = None,
|
|
290
|
+
**options: Any,
|
|
291
|
+
) -> str:
|
|
292
|
+
"""Generate one clip with sound and return its URL."""
|
|
293
|
+
task_id = submit(prompt, model, image_url, api_key=api_key, base_url=base_url, transport=transport, **options)
|
|
294
|
+
return wait(task_id, api_key=api_key, timeout=timeout, poll_every=poll_every, base_url=base_url, transport=transport)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def credits(
|
|
298
|
+
*,
|
|
299
|
+
api_key: Optional[str] = None,
|
|
300
|
+
base_url: Optional[str] = None,
|
|
301
|
+
transport: Optional[Transport] = None,
|
|
302
|
+
) -> int:
|
|
303
|
+
"""Return the remaining credits on the account."""
|
|
304
|
+
env = _call("/api/user/get-user-credits", None, api_key, base_url, transport)
|
|
305
|
+
return int((env.get("data") or {}).get("remainingCredits") or 0)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: movoa
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Unlimited AI video generator with sound: on paid plans the tenth take of a shot costs what the first did, and your first clip is free to make right now. Official Python SDK for Movoa (movoa.ai): text to video and photo to video with sound from your own code.
|
|
5
|
+
Author: Movoa (https://movoa.ai)
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://movoa.ai/?utm_source=pypi&utm_medium=package
|
|
8
|
+
Project-URL: Text to video, https://movoa.ai/video/text-to-video
|
|
9
|
+
Project-URL: API keys, https://movoa.ai/settings/apikeys
|
|
10
|
+
Project-URL: Pricing, https://movoa.ai/pricing
|
|
11
|
+
Project-URL: Source, https://github.com/hanshs474/movoa-py
|
|
12
|
+
Keywords: ai,video-generation,ai-video-generator,text-to-video,image-to-video,photo-to-video,ai-video-with-audio,minimax-h3,seedance-2-5,veo-3,movoa
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: Multimedia :: Video
|
|
18
|
+
Requires-Python: >=3.8
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# movoa
|
|
24
|
+
|
|
25
|
+
Unlimited AI video generator with sound: on paid plans the tenth take of a shot costs what the first did, and your first clip is free to make right now. This is the official Python SDK for [Movoa](https://movoa.ai/?utm_source=pypi&utm_medium=package): one API key turns a prompt into [text to video](https://movoa.ai/video/text-to-video), or a still into [photo to video](https://movoa.ai/video/photo-to-video), and every clip comes back as [AI video with audio](https://movoa.ai/video/ai-video-with-audio).
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install movoa
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from movoa import generate_video
|
|
33
|
+
|
|
34
|
+
clip = generate_video("a paper boat drifting down a rain gutter, low angle, slow push-in")
|
|
35
|
+
print(clip) # URL of the finished MP4, sound included
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Standard library only, no dependencies. `generate_video` submits the run, waits for it and returns the URL of the finished file.
|
|
39
|
+
|
|
40
|
+
## API key
|
|
41
|
+
|
|
42
|
+
Sign in on [movoa.ai](https://movoa.ai/?utm_source=pypi&utm_medium=package), create a key at [movoa.ai/settings/apikeys](https://movoa.ai/settings/apikeys), then pass it as `api_key=` or export it:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
export MOVOA_API_KEY=sk-...
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Runs cost credits on your Movoa account, at the prices on [movoa.ai/pricing](https://movoa.ai/pricing).
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from movoa import credits
|
|
52
|
+
print(credits()) # remaining credits on the account
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Text to video and photo to video
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from movoa import generate_video
|
|
59
|
+
|
|
60
|
+
# text to video, vertical, 10 seconds
|
|
61
|
+
generate_video("a fox trotting through fresh snow at dawn, tracking shot",
|
|
62
|
+
duration=10, aspect_ratio="9:16")
|
|
63
|
+
|
|
64
|
+
# photo to video: the photo becomes the first frame
|
|
65
|
+
generate_video("the camera slowly orbits the statue", image_url="https://example.com/statue.jpg")
|
|
66
|
+
|
|
67
|
+
# another model
|
|
68
|
+
generate_video("a street drummer in the rain, close-up", model="seedance-2-5", duration=10, resolution="720p")
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
| model | options |
|
|
72
|
+
|---|---|
|
|
73
|
+
| `minimax-h3` (default) | `duration` 4 / 5 / 6 / 8 / 10 / 15 · `resolution` 480p / 768p · `aspect_ratio` 16:9 / 9:16 / 1:1 |
|
|
74
|
+
| `veo-3-1-fast` | `duration` 8 · `resolution` 720p · `aspect_ratio` 16:9 / 9:16 (photo to video: 16:9) |
|
|
75
|
+
| `seedance-2-5` | `duration` 5 / 10 / 15 / 20 / 25 / 30 · `resolution` 480p / 720p · `aspect_ratio` 16:9 / 9:16 / 1:1 |
|
|
76
|
+
| `seedance-2`, `seedance-2-fast`, `seedance-2-mini` | `duration` 4 / 6 / 8 / 10 · `resolution` 480p / 720p · `aspect_ratio` |
|
|
77
|
+
|
|
78
|
+
Any extra keyword argument is sent in `options` as it is. Every run asks for sound. On the site: [Seedance 2.5](https://movoa.ai/video/seedance-2-5) and [Veo 3](https://movoa.ai/video/google-veo-3).
|
|
79
|
+
|
|
80
|
+
## Submit now, collect later
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from movoa import submit, status, wait
|
|
84
|
+
|
|
85
|
+
task_id = submit("a lighthouse in a storm, waves crashing", "minimax-h3", duration=6)
|
|
86
|
+
print(status(task_id)["status"]) # pending | processing | success | failed | canceled
|
|
87
|
+
url = wait(task_id) # polls every 10 seconds, 30 minutes by default
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
While a run waits in line, `status()` returns `queued: True` and `queueEta` in seconds.
|
|
91
|
+
|
|
92
|
+
## Errors worth catching
|
|
93
|
+
|
|
94
|
+
Each failure is its own subclass of `MovoaError`, so you can branch on what to do next:
|
|
95
|
+
|
|
96
|
+
- `AuthError`: no key, or the key is wrong or deleted; create one at [movoa.ai/settings/apikeys](https://movoa.ai/settings/apikeys)
|
|
97
|
+
- `QuotaError`: the account does not have enough credits for this run
|
|
98
|
+
- `PlanError`: that model or quality needs a plan on the account
|
|
99
|
+
- `BusyError`: too many runs on this account are generating at once
|
|
100
|
+
- `RejectedError`: the content filter refused the prompt, or a cooldown is active; reword it
|
|
101
|
+
- `GenerationError`: the run was accepted but failed or was canceled; `.error_message` says why
|
|
102
|
+
- `TimeoutError`: the wait ran out; `.task_id` lets you check the run later with `status()`
|
|
103
|
+
|
|
104
|
+
Any other refusal raises `MovoaError` with the server's message.
|
|
105
|
+
|
|
106
|
+
MIT licensed. Made by [Movoa](https://movoa.ai/?utm_source=pypi&utm_medium=package).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
movoa
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import unittest
|
|
4
|
+
|
|
5
|
+
import movoa
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def reply(status, body):
|
|
9
|
+
raw = body if isinstance(body, bytes) else json.dumps(body).encode()
|
|
10
|
+
return status, raw
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def fake(calls, polls, submit_reply=None):
|
|
14
|
+
"""Answers in the real response shapes of movoa.ai."""
|
|
15
|
+
polls = list(polls)
|
|
16
|
+
submit_reply = submit_reply or reply(200, {"code": 0, "message": "ok", "data": {"id": "t1", "status": "pending"}})
|
|
17
|
+
|
|
18
|
+
def t(method, url, body, headers):
|
|
19
|
+
calls.append((method, url, body, headers))
|
|
20
|
+
if url.endswith("/api/ai/generate"):
|
|
21
|
+
return submit_reply
|
|
22
|
+
if url.endswith("/api/ai/query"):
|
|
23
|
+
return reply(200, {"code": 0, "message": "ok", "data": polls.pop(0)})
|
|
24
|
+
return reply(200, {"code": 0, "message": "ok", "data": {"remainingCredits": 42}})
|
|
25
|
+
|
|
26
|
+
return t
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class MovoaTests(unittest.TestCase):
|
|
30
|
+
def setUp(self):
|
|
31
|
+
self.live_key = os.environ.pop("MOVOA_API_KEY", None)
|
|
32
|
+
os.environ.pop("MOVOA_BASE_URL", None)
|
|
33
|
+
|
|
34
|
+
def test_text_to_video_success(self):
|
|
35
|
+
calls = []
|
|
36
|
+
polls = [
|
|
37
|
+
{"id": "t1", "status": "pending", "queued": True, "queueEta": 30},
|
|
38
|
+
{"id": "t1", "status": "processing"},
|
|
39
|
+
{"id": "t1", "status": "success", "videos": ["https://cdn/v.mp4"]},
|
|
40
|
+
]
|
|
41
|
+
url = movoa.generate_video("a boat", api_key="sk-abc", duration=6, poll_every=0, base_url="https://t", transport=fake(calls, polls))
|
|
42
|
+
self.assertEqual(url, "https://cdn/v.mp4")
|
|
43
|
+
method, u, body, headers = calls[0]
|
|
44
|
+
self.assertEqual((method, u), ("POST", "https://t/api/ai/generate"))
|
|
45
|
+
self.assertEqual(headers["Authorization"], "Bearer sk-abc")
|
|
46
|
+
self.assertEqual(body, {
|
|
47
|
+
"provider": "auto", "mediaType": "video", "model": "minimax-h3", "scene": "text-to-video",
|
|
48
|
+
"prompt": "a boat", "options": {"sound": True, "duration": 6},
|
|
49
|
+
})
|
|
50
|
+
self.assertEqual(calls[1][2], {"taskId": "t1"})
|
|
51
|
+
self.assertEqual(len(calls), 4)
|
|
52
|
+
|
|
53
|
+
def test_image_to_video_body(self):
|
|
54
|
+
calls = []
|
|
55
|
+
movoa.submit("orbit", "seedance-2-5", image_url="https://x/y.jpg", api_key="sk", aspect_ratio="9:16", transport=fake(calls, []))
|
|
56
|
+
body = calls[0][2]
|
|
57
|
+
self.assertEqual(body["scene"], "image-to-video")
|
|
58
|
+
self.assertEqual(body["model"], "seedance-2-5")
|
|
59
|
+
self.assertEqual(body["options"], {"sound": True, "aspect_ratio": "9:16", "image_input": ["https://x/y.jpg"]})
|
|
60
|
+
|
|
61
|
+
def test_base_url_from_env(self):
|
|
62
|
+
os.environ["MOVOA_BASE_URL"] = "http://localhost:3000/"
|
|
63
|
+
calls = []
|
|
64
|
+
movoa.credits(api_key="sk", transport=fake(calls, []))
|
|
65
|
+
self.assertEqual(calls[0][1], "http://localhost:3000/api/user/get-user-credits")
|
|
66
|
+
|
|
67
|
+
def test_validates_prompt(self):
|
|
68
|
+
with self.assertRaises(ValueError):
|
|
69
|
+
movoa.submit(" ", api_key="sk")
|
|
70
|
+
|
|
71
|
+
def test_needs_a_key(self):
|
|
72
|
+
def boom(*a):
|
|
73
|
+
raise AssertionError("no request without a key")
|
|
74
|
+
|
|
75
|
+
with self.assertRaises(movoa.AuthError):
|
|
76
|
+
movoa.credits(transport=boom)
|
|
77
|
+
|
|
78
|
+
def test_error_mapping(self):
|
|
79
|
+
cases = [
|
|
80
|
+
(reply(401, {"code": -1, "message": "Invalid API key. Create one at https://movoa.ai/settings/apikeys", "data": {"reason": "invalid_api_key"}}), movoa.AuthError),
|
|
81
|
+
(reply(401, b"<html>unauthorized</html>"), movoa.AuthError),
|
|
82
|
+
(reply(200, {"code": -1, "message": "insufficient credits", "data": {"reason": "insufficient_credits"}}), movoa.QuotaError),
|
|
83
|
+
(reply(200, {"code": -1, "message": "This model or quality needs a plan.", "data": {"reason": "plan_required"}}), movoa.PlanError),
|
|
84
|
+
(reply(200, {"code": -1, "message": "busy", "data": {"reason": "concurrency"}}), movoa.BusyError),
|
|
85
|
+
(reply(200, {"code": -2, "message": "blocked"}), movoa.RejectedError),
|
|
86
|
+
(reply(200, {"code": -3, "message": "cooldown"}), movoa.RejectedError),
|
|
87
|
+
(reply(200, {"code": -1, "message": "no auth, please sign in"}), movoa.AuthError),
|
|
88
|
+
]
|
|
89
|
+
for r, exc in cases:
|
|
90
|
+
with self.assertRaises(exc, msg=str(r)):
|
|
91
|
+
movoa.submit("x", api_key="sk", transport=fake([], [], r))
|
|
92
|
+
for r in (reply(200, {"code": -1, "message": "generation failed, please try again"}), reply(502, b"Bad gateway"), reply(500, {"code": -1, "message": "oops"})):
|
|
93
|
+
with self.assertRaises(movoa.MovoaError) as cm:
|
|
94
|
+
movoa.submit("x", api_key="sk", transport=fake([], [], r))
|
|
95
|
+
self.assertIs(type(cm.exception), movoa.MovoaError)
|
|
96
|
+
with self.assertRaises(movoa.MovoaError) as cm:
|
|
97
|
+
movoa.submit("x", api_key="sk", transport=fake([], [], reply(200, {"code": -1, "message": "generation failed, please try again"})))
|
|
98
|
+
self.assertIn("generation failed", str(cm.exception))
|
|
99
|
+
|
|
100
|
+
def test_failed_and_canceled(self):
|
|
101
|
+
with self.assertRaises(movoa.GenerationError) as cm:
|
|
102
|
+
movoa.generate_video("x", api_key="sk", poll_every=0, transport=fake([], [{"id": "t1", "status": "failed", "errorMessage": "provider down", "failureKind": "upstream"}]))
|
|
103
|
+
self.assertEqual(cm.exception.error_message, "provider down")
|
|
104
|
+
self.assertEqual(cm.exception.failure_kind, "upstream")
|
|
105
|
+
self.assertEqual(cm.exception.task_id, "t1")
|
|
106
|
+
self.assertIn("provider down", str(cm.exception))
|
|
107
|
+
with self.assertRaises(movoa.GenerationError):
|
|
108
|
+
movoa.wait("t1", api_key="sk", poll_every=0, transport=fake([], [{"id": "t1", "status": "canceled"}]))
|
|
109
|
+
|
|
110
|
+
def test_timeout_carries_task_id(self):
|
|
111
|
+
polls = [{"id": "t1", "status": "processing", "queued": True, "queueEta": 60}] * 5
|
|
112
|
+
with self.assertRaises(movoa.TimeoutError) as cm:
|
|
113
|
+
movoa.wait("t1", api_key="sk", timeout=0.001, poll_every=0.01, transport=fake([], polls))
|
|
114
|
+
self.assertEqual(cm.exception.task_id, "t1")
|
|
115
|
+
|
|
116
|
+
def test_status_and_credits(self):
|
|
117
|
+
st = movoa.status("t1", api_key="sk", transport=fake([], [{"id": "t1", "status": "success", "videos": ["https://cdn/v.mp4"]}]))
|
|
118
|
+
self.assertEqual(st["videos"][0], "https://cdn/v.mp4")
|
|
119
|
+
self.assertEqual(movoa.credits(api_key="sk", transport=fake([], [])), 42)
|
|
120
|
+
|
|
121
|
+
def no_auth(method, url, body, headers):
|
|
122
|
+
return reply(200, {"code": -1, "message": "no auth, please sign in"})
|
|
123
|
+
|
|
124
|
+
with self.assertRaises(movoa.AuthError):
|
|
125
|
+
movoa.credits(api_key="sk", transport=no_auth)
|
|
126
|
+
|
|
127
|
+
@unittest.skipUnless(os.environ.get("MOVOA_LIVE"), "set MOVOA_LIVE=1 MOVOA_API_KEY=sk-... after key auth is live")
|
|
128
|
+
def test_live_smoke(self):
|
|
129
|
+
self.assertGreaterEqual(movoa.credits(api_key=self.live_key), 0)
|
|
130
|
+
with self.assertRaises(movoa.AuthError):
|
|
131
|
+
movoa.submit("test", api_key="sk-wrong-key-for-test")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
if __name__ == "__main__":
|
|
135
|
+
unittest.main()
|