runmux 0.1.0__py3-none-any.whl
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.
- runmux/__init__.py +28 -0
- runmux/assets.py +83 -0
- runmux/client.py +132 -0
- runmux/errors.py +54 -0
- runmux/faces.py +59 -0
- runmux/files.py +64 -0
- runmux/videos.py +163 -0
- runmux/webhooks.py +32 -0
- runmux-0.1.0.dist-info/METADATA +168 -0
- runmux-0.1.0.dist-info/RECORD +11 -0
- runmux-0.1.0.dist-info/WHEEL +4 -0
runmux/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""RunMux — official Python SDK.
|
|
2
|
+
|
|
3
|
+
Generate video with Seedance 2.0 and manage the face asset library, with
|
|
4
|
+
submit-and-wait helpers so you never hand-roll polling.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .assets import Assets
|
|
10
|
+
from .client import RunmuxClient
|
|
11
|
+
from .errors import RunmuxError
|
|
12
|
+
from .faces import Faces
|
|
13
|
+
from .files import Files
|
|
14
|
+
from .videos import Videos
|
|
15
|
+
from .webhooks import Webhooks
|
|
16
|
+
|
|
17
|
+
__version__ = "0.1.0"
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"RunmuxClient",
|
|
21
|
+
"RunmuxError",
|
|
22
|
+
"Videos",
|
|
23
|
+
"Assets",
|
|
24
|
+
"Faces",
|
|
25
|
+
"Files",
|
|
26
|
+
"Webhooks",
|
|
27
|
+
"__version__",
|
|
28
|
+
]
|
runmux/assets.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""The face / portrait asset library: register media and poll until active."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
|
7
|
+
from urllib.parse import quote
|
|
8
|
+
|
|
9
|
+
from .errors import RunmuxError
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
12
|
+
from .client import RunmuxClient
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Assets:
|
|
16
|
+
def __init__(self, client: "RunmuxClient") -> None:
|
|
17
|
+
self._client = client
|
|
18
|
+
|
|
19
|
+
def create(
|
|
20
|
+
self,
|
|
21
|
+
*,
|
|
22
|
+
url: Optional[str] = None,
|
|
23
|
+
data_url: Optional[str] = None,
|
|
24
|
+
name: Optional[str] = None,
|
|
25
|
+
asset_type: Optional[str] = None,
|
|
26
|
+
) -> Dict[str, Any]:
|
|
27
|
+
"""Register a face/portrait asset by public URL or inline ``data_url``.
|
|
28
|
+
|
|
29
|
+
Returns a ``processing`` record. ``asset_type`` is ``"Image"`` or
|
|
30
|
+
``"Video"``.
|
|
31
|
+
"""
|
|
32
|
+
body: Dict[str, Any] = {}
|
|
33
|
+
if url is not None:
|
|
34
|
+
body["url"] = url
|
|
35
|
+
if data_url is not None:
|
|
36
|
+
body["dataUrl"] = data_url
|
|
37
|
+
if name is not None:
|
|
38
|
+
body["name"] = name
|
|
39
|
+
if asset_type is not None:
|
|
40
|
+
body["assetType"] = asset_type
|
|
41
|
+
return self._client.request("POST", "/v1/assets", body=body)
|
|
42
|
+
|
|
43
|
+
def get(self, id: str) -> Dict[str, Any]:
|
|
44
|
+
return self._client.request("GET", f"/v1/assets/{quote(id, safe='')}")
|
|
45
|
+
|
|
46
|
+
def list(self) -> List[Dict[str, Any]]:
|
|
47
|
+
res = self._client.request("GET", "/v1/assets")
|
|
48
|
+
if isinstance(res, dict):
|
|
49
|
+
return res.get("data") or []
|
|
50
|
+
return []
|
|
51
|
+
|
|
52
|
+
def delete(self, id: str) -> None:
|
|
53
|
+
self._client.request("DELETE", f"/v1/assets/{quote(id, safe='')}")
|
|
54
|
+
|
|
55
|
+
def wait_active(
|
|
56
|
+
self,
|
|
57
|
+
id: str,
|
|
58
|
+
interval: float = 3,
|
|
59
|
+
timeout: float = 120,
|
|
60
|
+
) -> Dict[str, Any]:
|
|
61
|
+
"""Poll an asset until it leaves ``processing``.
|
|
62
|
+
|
|
63
|
+
Returns the active asset record. Raises ``RunmuxError`` (carrying
|
|
64
|
+
``statusReason``) if the asset fails or is rejected, or on timeout
|
|
65
|
+
(seconds).
|
|
66
|
+
"""
|
|
67
|
+
deadline = time.monotonic() + timeout
|
|
68
|
+
while True:
|
|
69
|
+
asset = self.get(id)
|
|
70
|
+
status = asset.get("status")
|
|
71
|
+
if status == "active":
|
|
72
|
+
return asset
|
|
73
|
+
if status in ("failed", "rejected"):
|
|
74
|
+
raise RunmuxError(
|
|
75
|
+
asset.get("statusReason") or "Asset failed processing.",
|
|
76
|
+
code="bad_request",
|
|
77
|
+
)
|
|
78
|
+
if time.monotonic() >= deadline:
|
|
79
|
+
raise RunmuxError(
|
|
80
|
+
f"Timed out waiting for asset {id} to become active.",
|
|
81
|
+
code="upstream_timeout",
|
|
82
|
+
)
|
|
83
|
+
time.sleep(interval)
|
runmux/client.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""The RunMux client — construct once with your API key, then use the resource
|
|
2
|
+
namespaces: ``client.videos``, ``client.files``, ``client.assets``,
|
|
3
|
+
``client.faces``, ``client.webhooks``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from typing import Any, Dict, Mapping, Optional
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from .assets import Assets
|
|
14
|
+
from .errors import RunmuxError
|
|
15
|
+
from .faces import Faces
|
|
16
|
+
from .files import Files
|
|
17
|
+
from .videos import Videos
|
|
18
|
+
from .webhooks import Webhooks
|
|
19
|
+
|
|
20
|
+
DEFAULT_BASE_URL = "https://api.runmux.com"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RunmuxClient:
|
|
24
|
+
"""Synchronous RunMux client.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
api_key: Your RunMux API key (Bearer). Falls back to the
|
|
28
|
+
``RUNMUX_API_KEY`` environment variable.
|
|
29
|
+
base_url: API base URL. Default ``https://api.runmux.com``.
|
|
30
|
+
timeout: Per-request timeout in seconds. Default ``60``.
|
|
31
|
+
transport: Optional ``httpx`` transport override (for tests / custom
|
|
32
|
+
transports).
|
|
33
|
+
|
|
34
|
+
Raises:
|
|
35
|
+
RunmuxError: with ``code="invalid_key"`` if no API key is provided.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
api_key: Optional[str] = None,
|
|
41
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
42
|
+
timeout: float = 60,
|
|
43
|
+
*,
|
|
44
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
key = api_key if api_key is not None else os.environ.get("RUNMUX_API_KEY")
|
|
47
|
+
if not key:
|
|
48
|
+
raise RunmuxError(
|
|
49
|
+
"A RunMux API key is required (pass api_key or set RUNMUX_API_KEY).",
|
|
50
|
+
code="invalid_key",
|
|
51
|
+
)
|
|
52
|
+
self._api_key = key
|
|
53
|
+
self.base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
|
|
54
|
+
self.timeout = timeout
|
|
55
|
+
self._http = httpx.Client(
|
|
56
|
+
base_url=self.base_url,
|
|
57
|
+
timeout=timeout,
|
|
58
|
+
transport=transport,
|
|
59
|
+
headers={
|
|
60
|
+
"authorization": f"Bearer {self._api_key}",
|
|
61
|
+
"content-type": "application/json",
|
|
62
|
+
},
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
self.videos = Videos(self)
|
|
66
|
+
self.files = Files(self)
|
|
67
|
+
self.assets = Assets(self)
|
|
68
|
+
self.faces = Faces(self)
|
|
69
|
+
self.webhooks = Webhooks()
|
|
70
|
+
|
|
71
|
+
# -- context manager / lifecycle ------------------------------------
|
|
72
|
+
|
|
73
|
+
def close(self) -> None:
|
|
74
|
+
"""Close the underlying HTTP connection pool."""
|
|
75
|
+
self._http.close()
|
|
76
|
+
|
|
77
|
+
def __enter__(self) -> "RunmuxClient":
|
|
78
|
+
return self
|
|
79
|
+
|
|
80
|
+
def __exit__(self, *exc: Any) -> None:
|
|
81
|
+
self.close()
|
|
82
|
+
|
|
83
|
+
# -- low-level request ----------------------------------------------
|
|
84
|
+
|
|
85
|
+
def request(
|
|
86
|
+
self,
|
|
87
|
+
method: str,
|
|
88
|
+
path: str,
|
|
89
|
+
*,
|
|
90
|
+
body: Any = None,
|
|
91
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
92
|
+
) -> Any:
|
|
93
|
+
"""Low-level JSON request against the RunMux API.
|
|
94
|
+
|
|
95
|
+
Raises ``RunmuxError`` on any non-2xx response, parsing the API's
|
|
96
|
+
``{"error": {code, message, request_id}}`` envelope when present.
|
|
97
|
+
"""
|
|
98
|
+
try:
|
|
99
|
+
response = self._http.request(
|
|
100
|
+
method,
|
|
101
|
+
path,
|
|
102
|
+
json=body if body is not None else None,
|
|
103
|
+
headers=dict(headers) if headers else None,
|
|
104
|
+
)
|
|
105
|
+
except httpx.TimeoutException as exc:
|
|
106
|
+
raise RunmuxError(
|
|
107
|
+
"Request to RunMux timed out.", code="upstream_timeout"
|
|
108
|
+
) from exc
|
|
109
|
+
except httpx.HTTPError as exc:
|
|
110
|
+
raise RunmuxError(
|
|
111
|
+
str(exc) or "Network request failed.", code="upstream_unavailable"
|
|
112
|
+
) from exc
|
|
113
|
+
|
|
114
|
+
text = response.text
|
|
115
|
+
data: Any = None
|
|
116
|
+
if text:
|
|
117
|
+
try:
|
|
118
|
+
data = response.json()
|
|
119
|
+
except ValueError:
|
|
120
|
+
data = None
|
|
121
|
+
|
|
122
|
+
if not response.is_success:
|
|
123
|
+
err: Dict[str, Any] = {}
|
|
124
|
+
if isinstance(data, dict) and isinstance(data.get("error"), dict):
|
|
125
|
+
err = data["error"]
|
|
126
|
+
raise RunmuxError(
|
|
127
|
+
err.get("message") or f"RunMux request failed ({response.status_code}).",
|
|
128
|
+
status=response.status_code,
|
|
129
|
+
code=err.get("code") or "upstream_unavailable",
|
|
130
|
+
request_id=err.get("request_id"),
|
|
131
|
+
)
|
|
132
|
+
return data
|
runmux/errors.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Error type for the RunMux SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
# Stable error codes returned by the RunMux API (mirrors the server's error codes).
|
|
8
|
+
ERROR_CODES = (
|
|
9
|
+
"invalid_key",
|
|
10
|
+
"insufficient_balance",
|
|
11
|
+
"model_not_allowed",
|
|
12
|
+
"rate_limited",
|
|
13
|
+
"upstream_timeout",
|
|
14
|
+
"stream_interrupted",
|
|
15
|
+
"upstream_unavailable",
|
|
16
|
+
"internal_error",
|
|
17
|
+
"permission_denied",
|
|
18
|
+
"bad_request",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RunmuxError(Exception):
|
|
23
|
+
"""Raised for any non-2xx RunMux API response, or when a job/asset reaches a
|
|
24
|
+
failed terminal state.
|
|
25
|
+
|
|
26
|
+
Carries the API's ``code`` and ``request_id`` so callers can branch and
|
|
27
|
+
report precisely.
|
|
28
|
+
|
|
29
|
+
Attributes:
|
|
30
|
+
message: Human-readable description.
|
|
31
|
+
status: HTTP status code (0 when not from an HTTP response).
|
|
32
|
+
code: A stable machine-readable error code (see ``ERROR_CODES``).
|
|
33
|
+
request_id: Server-side request id for support, when available.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
message: str,
|
|
39
|
+
*,
|
|
40
|
+
status: int = 0,
|
|
41
|
+
code: str = "internal_error",
|
|
42
|
+
request_id: Optional[str] = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
super().__init__(message)
|
|
45
|
+
self.message = message
|
|
46
|
+
self.status = status
|
|
47
|
+
self.code = code
|
|
48
|
+
self.request_id = request_id
|
|
49
|
+
|
|
50
|
+
def __repr__(self) -> str: # pragma: no cover - cosmetic
|
|
51
|
+
return (
|
|
52
|
+
f"RunmuxError(message={self.message!r}, status={self.status!r}, "
|
|
53
|
+
f"code={self.code!r}, request_id={self.request_id!r})"
|
|
54
|
+
)
|
runmux/faces.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""One-call face onboarding into the asset library."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
8
|
+
from .client import RunmuxClient
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Faces:
|
|
12
|
+
def __init__(self, client: "RunmuxClient") -> None:
|
|
13
|
+
self._client = client
|
|
14
|
+
|
|
15
|
+
def enroll(
|
|
16
|
+
self,
|
|
17
|
+
*,
|
|
18
|
+
url: Optional[str] = None,
|
|
19
|
+
data_url: Optional[str] = None,
|
|
20
|
+
name: Optional[str] = None,
|
|
21
|
+
interval: float = 3,
|
|
22
|
+
timeout: float = 120,
|
|
23
|
+
) -> str:
|
|
24
|
+
"""One-call face onboarding.
|
|
25
|
+
|
|
26
|
+
Registers the image in the asset library, waits until it is active, and
|
|
27
|
+
returns the ready-to-use ``asset://<id>`` reference.
|
|
28
|
+
|
|
29
|
+
Pass an ordinary (non-celebrity) face photo via ``url`` (a public https
|
|
30
|
+
image URL) or ``data_url`` (an inline ``data:`` URI / base64 image).
|
|
31
|
+
|
|
32
|
+
Raises ``RunmuxError`` (with the moderation reason) if the face is
|
|
33
|
+
rejected — e.g. a celebrity / copyrighted likeness.
|
|
34
|
+
"""
|
|
35
|
+
created = self._client.assets.create(
|
|
36
|
+
url=url, data_url=data_url, name=name, asset_type="Image"
|
|
37
|
+
)
|
|
38
|
+
active = self._client.assets.wait_active(
|
|
39
|
+
created["id"], interval=interval, timeout=timeout
|
|
40
|
+
)
|
|
41
|
+
return active["assetUri"]
|
|
42
|
+
|
|
43
|
+
def enroll_asset(
|
|
44
|
+
self,
|
|
45
|
+
*,
|
|
46
|
+
url: Optional[str] = None,
|
|
47
|
+
data_url: Optional[str] = None,
|
|
48
|
+
name: Optional[str] = None,
|
|
49
|
+
interval: float = 3,
|
|
50
|
+
timeout: float = 120,
|
|
51
|
+
) -> Dict[str, Any]:
|
|
52
|
+
"""Same as :meth:`enroll` but returns the full asset record instead of
|
|
53
|
+
just the ``assetUri``."""
|
|
54
|
+
created = self._client.assets.create(
|
|
55
|
+
url=url, data_url=data_url, name=name, asset_type="Image"
|
|
56
|
+
)
|
|
57
|
+
return self._client.assets.wait_active(
|
|
58
|
+
created["id"], interval=interval, timeout=timeout
|
|
59
|
+
)
|
runmux/files.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Presigned direct-to-storage uploads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Dict, Union
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from .errors import RunmuxError
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
12
|
+
from .client import RunmuxClient
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Files:
|
|
16
|
+
def __init__(self, client: "RunmuxClient") -> None:
|
|
17
|
+
self._client = client
|
|
18
|
+
|
|
19
|
+
def create_upload(self, content_type: str) -> Dict[str, Any]:
|
|
20
|
+
"""Ask RunMux for a presigned direct-to-storage upload URL for a given
|
|
21
|
+
content type.
|
|
22
|
+
|
|
23
|
+
Returns a dict with snake_case keys: ``id``, ``upload_url``,
|
|
24
|
+
``file_url``, ``method``, ``headers``, ``expires_in``.
|
|
25
|
+
"""
|
|
26
|
+
raw = self._client.request(
|
|
27
|
+
"POST", "/v1/files", body={"content_type": content_type}
|
|
28
|
+
)
|
|
29
|
+
return {
|
|
30
|
+
"id": raw.get("id"),
|
|
31
|
+
"upload_url": raw.get("upload_url"),
|
|
32
|
+
"file_url": raw.get("file_url"),
|
|
33
|
+
"method": raw.get("method"),
|
|
34
|
+
"headers": raw.get("headers") or {},
|
|
35
|
+
"expires_in": raw.get("expires_in"),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
def upload(self, data: Union[bytes, bytearray], content_type: str) -> str:
|
|
39
|
+
"""Upload bytes via a presigned URL and return the resulting ``file_url``.
|
|
40
|
+
|
|
41
|
+
The returned URL is usable as ``image_url`` / ``video_url`` / reference
|
|
42
|
+
media on :meth:`Videos.create`. Does the presign + PUT for you.
|
|
43
|
+
"""
|
|
44
|
+
presign = self.create_upload(content_type)
|
|
45
|
+
method = presign.get("method") or "PUT"
|
|
46
|
+
try:
|
|
47
|
+
res = httpx.request(
|
|
48
|
+
method,
|
|
49
|
+
presign["upload_url"],
|
|
50
|
+
content=bytes(data),
|
|
51
|
+
headers={"content-type": content_type},
|
|
52
|
+
timeout=self._client.timeout,
|
|
53
|
+
)
|
|
54
|
+
except httpx.HTTPError as exc:
|
|
55
|
+
raise RunmuxError(
|
|
56
|
+
str(exc) or "Upload request failed.", code="upstream_unavailable"
|
|
57
|
+
) from exc
|
|
58
|
+
if not res.is_success:
|
|
59
|
+
raise RunmuxError(
|
|
60
|
+
f"Upload PUT failed ({res.status_code}).",
|
|
61
|
+
status=res.status_code,
|
|
62
|
+
code="upstream_unavailable",
|
|
63
|
+
)
|
|
64
|
+
return presign["file_url"]
|
runmux/videos.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Video generation: submit, poll, and the one-call submit-and-wait helper."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
|
|
7
|
+
from urllib.parse import quote
|
|
8
|
+
|
|
9
|
+
from .errors import RunmuxError
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
12
|
+
from .client import RunmuxClient
|
|
13
|
+
|
|
14
|
+
# Pythonic snake_case input key -> exact wire field name the RunMux API reads.
|
|
15
|
+
# Most fields are snake_case on the wire too; a handful are camelCase (copied
|
|
16
|
+
# verbatim from the JS SDK's toWireBody).
|
|
17
|
+
_SNAKE_TO_WIRE = {
|
|
18
|
+
"model": "model",
|
|
19
|
+
"prompt": "prompt",
|
|
20
|
+
"image_url": "image_url",
|
|
21
|
+
"video_url": "video_url",
|
|
22
|
+
"reference_images": "reference_images",
|
|
23
|
+
"reference_videos": "reference_videos",
|
|
24
|
+
"reference_audios": "reference_audios",
|
|
25
|
+
# frame_images handled specially below
|
|
26
|
+
"resolution": "resolution",
|
|
27
|
+
"duration": "duration",
|
|
28
|
+
"ratio": "ratio",
|
|
29
|
+
"width": "width",
|
|
30
|
+
"height": "height",
|
|
31
|
+
"seed": "seed",
|
|
32
|
+
"audio": "audio",
|
|
33
|
+
"number_results": "number_results",
|
|
34
|
+
"auto_enroll_faces": "auto_enroll_faces",
|
|
35
|
+
# camelCase-on-the-wire fields:
|
|
36
|
+
"output_type": "outputType",
|
|
37
|
+
"output_format": "outputFormat",
|
|
38
|
+
"output_quality": "outputQuality",
|
|
39
|
+
"ttl": "ttl",
|
|
40
|
+
"upload_endpoint": "uploadEndpoint",
|
|
41
|
+
"safety": "safety",
|
|
42
|
+
"webhook_url": "webhook_url",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
# Keys that are consumed by the SDK and never forwarded as body fields.
|
|
46
|
+
_CONTROL_KEYS = {"idempotency_key"}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _normalize_frame_images(frame_images: Any) -> List[Any]:
|
|
50
|
+
"""A frame image is a URL/asset:// string, or a dict with an explicit frame."""
|
|
51
|
+
out: List[Any] = []
|
|
52
|
+
for f in frame_images:
|
|
53
|
+
if isinstance(f, str):
|
|
54
|
+
out.append(f)
|
|
55
|
+
elif isinstance(f, dict):
|
|
56
|
+
entry: Dict[str, Any] = {"image": f.get("image")}
|
|
57
|
+
if "frame" in f and f["frame"] is not None:
|
|
58
|
+
entry["frame"] = f["frame"]
|
|
59
|
+
out.append(entry)
|
|
60
|
+
else:
|
|
61
|
+
out.append(f)
|
|
62
|
+
return out
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def to_wire_body(input: Dict[str, Any]) -> Dict[str, Any]:
|
|
66
|
+
"""Map the SDK's snake_case input to the exact wire field names the API reads."""
|
|
67
|
+
body: Dict[str, Any] = {}
|
|
68
|
+
for key, value in input.items():
|
|
69
|
+
if key in _CONTROL_KEYS:
|
|
70
|
+
continue
|
|
71
|
+
if value is None:
|
|
72
|
+
continue
|
|
73
|
+
if key == "frame_images":
|
|
74
|
+
body["frame_images"] = _normalize_frame_images(value)
|
|
75
|
+
continue
|
|
76
|
+
wire_key = _SNAKE_TO_WIRE.get(key)
|
|
77
|
+
if wire_key is None:
|
|
78
|
+
# Unknown key: pass through unchanged so new server fields still work.
|
|
79
|
+
wire_key = key
|
|
80
|
+
body[wire_key] = value
|
|
81
|
+
return body
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _is_batch(x: Dict[str, Any]) -> bool:
|
|
85
|
+
return x.get("batchId") is not None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class Videos:
|
|
89
|
+
def __init__(self, client: "RunmuxClient") -> None:
|
|
90
|
+
self._client = client
|
|
91
|
+
|
|
92
|
+
def create(self, **input: Any) -> Dict[str, Any]:
|
|
93
|
+
"""Submit a video job.
|
|
94
|
+
|
|
95
|
+
Returns the submitted job (``{"id", "status"}``) — or a batch
|
|
96
|
+
(``{"batchId", "results"}``) when ``number_results > 1``. Does NOT wait.
|
|
97
|
+
Use :meth:`run` to submit-and-wait, or :meth:`wait` afterwards.
|
|
98
|
+
|
|
99
|
+
Only ``model`` and ``prompt`` are required. Accepts Pythonic snake_case
|
|
100
|
+
keys (e.g. ``image_url``, ``reference_images``, ``auto_enroll_faces``,
|
|
101
|
+
``number_results``); pass ``idempotency_key`` to dedup retries.
|
|
102
|
+
"""
|
|
103
|
+
idempotency_key = input.get("idempotency_key")
|
|
104
|
+
headers = (
|
|
105
|
+
{"idempotency-key": idempotency_key} if idempotency_key else None
|
|
106
|
+
)
|
|
107
|
+
return self._client.request(
|
|
108
|
+
"POST", "/v1/videos", body=to_wire_body(input), headers=headers
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def get(self, id: str) -> Dict[str, Any]:
|
|
112
|
+
"""Fetch the current state of a job."""
|
|
113
|
+
return self._client.request("GET", f"/v1/videos/{quote(id, safe='')}")
|
|
114
|
+
|
|
115
|
+
def wait(
|
|
116
|
+
self,
|
|
117
|
+
id: str,
|
|
118
|
+
interval: float = 4,
|
|
119
|
+
timeout: float = 600,
|
|
120
|
+
) -> Dict[str, Any]:
|
|
121
|
+
"""Poll a job id until it reaches a terminal state.
|
|
122
|
+
|
|
123
|
+
Raises ``RunmuxError`` if the job fails or the timeout (seconds) elapses.
|
|
124
|
+
"""
|
|
125
|
+
deadline = time.monotonic() + timeout
|
|
126
|
+
attempt = 0
|
|
127
|
+
while True:
|
|
128
|
+
job = self.get(id)
|
|
129
|
+
status = job.get("status")
|
|
130
|
+
if status == "succeeded":
|
|
131
|
+
return job
|
|
132
|
+
if status == "failed":
|
|
133
|
+
raise RunmuxError(
|
|
134
|
+
job.get("errorMessage") or "Video generation failed.",
|
|
135
|
+
code="upstream_unavailable",
|
|
136
|
+
)
|
|
137
|
+
if time.monotonic() >= deadline:
|
|
138
|
+
raise RunmuxError(
|
|
139
|
+
f"Timed out waiting for video {id}.", code="upstream_timeout"
|
|
140
|
+
)
|
|
141
|
+
# Gentle backoff, capped at 2x the base interval.
|
|
142
|
+
time.sleep(min(interval * (1 + attempt * 0.25), interval * 2))
|
|
143
|
+
attempt += 1
|
|
144
|
+
|
|
145
|
+
def run(
|
|
146
|
+
self,
|
|
147
|
+
*,
|
|
148
|
+
interval: float = 4,
|
|
149
|
+
timeout: float = 600,
|
|
150
|
+
**input: Any,
|
|
151
|
+
) -> Union[Dict[str, Any], List[Dict[str, Any]]]:
|
|
152
|
+
"""Submit AND wait for the result — the one-call path.
|
|
153
|
+
|
|
154
|
+
Returns the finished job (with ``url`` set) for a single result, or a
|
|
155
|
+
list of finished jobs for a batch (``number_results > 1``).
|
|
156
|
+
"""
|
|
157
|
+
submitted = self.create(**input)
|
|
158
|
+
if _is_batch(submitted):
|
|
159
|
+
return [
|
|
160
|
+
self.wait(r["id"], interval=interval, timeout=timeout)
|
|
161
|
+
for r in submitted.get("results", [])
|
|
162
|
+
]
|
|
163
|
+
return self.wait(submitted["id"], interval=interval, timeout=timeout)
|
runmux/webhooks.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Webhook signature verification."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Webhooks:
|
|
11
|
+
def verify(self, payload: str, signature: Optional[str], secret: str) -> bool:
|
|
12
|
+
"""Verify a RunMux webhook signature.
|
|
13
|
+
|
|
14
|
+
RunMux sends ``X-Runmux-Signature: sha256=<hex>`` where the hex is
|
|
15
|
+
``HMAC-SHA256(rawBody, secret)``. Pass the raw request body string
|
|
16
|
+
exactly as received (do not re-serialize a parsed object).
|
|
17
|
+
|
|
18
|
+
Returns ``True`` only on a constant-time match.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
payload: The raw request body string.
|
|
22
|
+
signature: The value of the ``X-Runmux-Signature`` header
|
|
23
|
+
(e.g. ``"sha256=abc..."``), or ``None``.
|
|
24
|
+
secret: Your webhook secret.
|
|
25
|
+
"""
|
|
26
|
+
if not signature or not secret:
|
|
27
|
+
return False
|
|
28
|
+
expected_hex = hmac.new(
|
|
29
|
+
secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256
|
|
30
|
+
).hexdigest()
|
|
31
|
+
expected = f"sha256={expected_hex}"
|
|
32
|
+
return hmac.compare_digest(expected, signature)
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: runmux
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official RunMux Python SDK — video generation (Seedance 2.0) and the face asset library, with submit-and-wait helpers so you never hand-roll polling.
|
|
5
|
+
Project-URL: Homepage, https://runmux.com
|
|
6
|
+
Author: RunMux
|
|
7
|
+
License: MIT
|
|
8
|
+
Keywords: ai-video,runmux,sdk,seedance,video-generation
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Requires-Dist: httpx>=0.27
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
21
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# runmux
|
|
25
|
+
|
|
26
|
+
Official Python SDK for **RunMux** — generate video with Seedance 2.0 and manage the
|
|
27
|
+
face asset library, without hand-rolling polling loops or the multi-step face upload.
|
|
28
|
+
|
|
29
|
+
Synchronous and Pythonic: construct one client, call resource methods with
|
|
30
|
+
keyword arguments, get plain dicts back.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install runmux
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Requires Python 3.9+ (built on [httpx](https://www.python-httpx.org/)).
|
|
39
|
+
|
|
40
|
+
## Quickstart — text to video (one call)
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
import os
|
|
44
|
+
from runmux import RunmuxClient
|
|
45
|
+
|
|
46
|
+
client = RunmuxClient(api_key=os.environ["RUNMUX_API_KEY"])
|
|
47
|
+
|
|
48
|
+
# run() submits the job AND waits for the result — no polling code on your side.
|
|
49
|
+
video = client.videos.run(
|
|
50
|
+
model="seedance-2-0-mini",
|
|
51
|
+
prompt="a diamond necklace rotating on black velvet, soft highlights, product ad",
|
|
52
|
+
resolution="480p",
|
|
53
|
+
duration=5,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
print(video["url"]) # downloadable result (expires ~1h unless you pass ttl)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The API key falls back to the `RUNMUX_API_KEY` environment variable, so
|
|
60
|
+
`RunmuxClient()` works when that variable is set.
|
|
61
|
+
|
|
62
|
+
Prefer to manage polling yourself? Use `create()` then `wait()`:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
job = client.videos.create(model="seedance-2-0-mini", prompt="...")
|
|
66
|
+
done = client.videos.wait(job["id"])
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Putting a person in the video (faces)
|
|
70
|
+
|
|
71
|
+
Raw human faces cannot be sent to the model directly. `faces.enroll()` runs the
|
|
72
|
+
whole flow — register, wait until active, return the ready-to-use `asset://`
|
|
73
|
+
reference:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
# Use an ordinary (non-celebrity) face photo.
|
|
77
|
+
asset_uri = client.faces.enroll(url="https://your-cdn.com/model.jpg")
|
|
78
|
+
|
|
79
|
+
video = client.videos.run(
|
|
80
|
+
model="seedance-2-0-mini",
|
|
81
|
+
prompt="Image 1 wearing the necklace, smiling at the camera",
|
|
82
|
+
image_url=asset_uri, # or reference_images=[asset_uri]
|
|
83
|
+
)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Even simpler — let RunMux enroll the face for you in one shot:
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
video = client.videos.run(
|
|
90
|
+
model="seedance-2-0-mini",
|
|
91
|
+
prompt="Image 1 wearing the necklace",
|
|
92
|
+
image_url="https://your-cdn.com/model.jpg",
|
|
93
|
+
auto_enroll_faces=True,
|
|
94
|
+
)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
If a face is rejected (e.g. a celebrity / copyrighted likeness), the call raises
|
|
98
|
+
a `RunmuxError` whose message explains why — switch to an ordinary face photo.
|
|
99
|
+
|
|
100
|
+
## Image-to-video, batches, and inputs
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
# Product image as the first frame:
|
|
104
|
+
client.videos.run(
|
|
105
|
+
model="seedance-2-0-mini",
|
|
106
|
+
prompt="the ring rotates",
|
|
107
|
+
frame_images=["https://cdn/ring.jpg"],
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# Several variations at once (number_results 1–4) returns a list:
|
|
111
|
+
variations = client.videos.run(
|
|
112
|
+
model="seedance-2-0-mini", prompt="...", number_results=3
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Upload a local file, then reference it:
|
|
116
|
+
with open("ring.png", "rb") as f:
|
|
117
|
+
file_url = client.files.upload(f.read(), "image/png")
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`frame_images` entries may be plain URL/`asset://` strings, or dicts with an
|
|
121
|
+
explicit frame, e.g. `{"image": "https://cdn/ring.jpg", "frame": "first"}`.
|
|
122
|
+
|
|
123
|
+
## Webhooks
|
|
124
|
+
|
|
125
|
+
Submit with `webhook_url` to get the result POSTed to you, then verify the
|
|
126
|
+
signature against the raw request body:
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
ok = client.webhooks.verify(
|
|
130
|
+
payload=raw_request_body, # the raw string body, not re-serialized
|
|
131
|
+
signature=request.headers.get("x-runmux-signature"),
|
|
132
|
+
secret=os.environ["RUNMUX_WEBHOOK_SECRET"],
|
|
133
|
+
)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
The signature scheme is `sha256=<hex>` where the hex is
|
|
137
|
+
`HMAC-SHA256(raw_body, secret)`; comparison is constant-time.
|
|
138
|
+
|
|
139
|
+
## Errors
|
|
140
|
+
|
|
141
|
+
Every non-2xx response (and any failed job/asset) raises a `RunmuxError` with
|
|
142
|
+
`.code`, `.status`, and `.request_id` for precise branching and support.
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
from runmux import RunmuxError
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
client.videos.run(model="seedance-2-0-mini", prompt="...")
|
|
149
|
+
except RunmuxError as exc:
|
|
150
|
+
print(exc.code, exc.status, exc.request_id)
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## API surface
|
|
154
|
+
|
|
155
|
+
- `client.videos` — `create`, `run`, `wait`, `get`
|
|
156
|
+
- `client.assets` — `create`, `get`, `list`, `delete`, `wait_active`
|
|
157
|
+
- `client.faces` — `enroll`, `enroll_asset`
|
|
158
|
+
- `client.files` — `create_upload`, `upload`
|
|
159
|
+
- `client.webhooks` — `verify`
|
|
160
|
+
|
|
161
|
+
## Wire field names
|
|
162
|
+
|
|
163
|
+
You pass clean Python snake_case keyword arguments; the SDK maps them to the
|
|
164
|
+
exact field names the API expects. For example `image_url`, `reference_images`,
|
|
165
|
+
`auto_enroll_faces`, and `number_results` are sent as-is, while `output_type`,
|
|
166
|
+
`output_format`, `output_quality`, and `upload_endpoint` are sent as their
|
|
167
|
+
camelCase wire equivalents. Pass `idempotency_key` to dedup retries — it is sent
|
|
168
|
+
as the `Idempotency-Key` header, not a body field.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
runmux/__init__.py,sha256=R_KSkPbSrRVfjL815BbR0FA8VLqIeL7U9uaaU4K6xKM,572
|
|
2
|
+
runmux/assets.py,sha256=rZvcikeobiDLMAwc2pqiZVPOVEHNCXpZbINQUdnSzrY,2694
|
|
3
|
+
runmux/client.py,sha256=T473umVyrh64MtebhkyrCWTREsxy6lmHsB2HpdbrOgw,4176
|
|
4
|
+
runmux/errors.py,sha256=syEr0CRS6H3XPAHpAPvKLIgaxJecyAKhK_nYqr6N35U,1542
|
|
5
|
+
runmux/faces.py,sha256=ZAfy3ShRxTlV7sZaZk2Ly09_gBDkNH7O-298qLh5__w,1920
|
|
6
|
+
runmux/files.py,sha256=grtjmmbn5KCuwHGYkgUo5fH3QeFgoXgydlwHVnPd_Do,2195
|
|
7
|
+
runmux/videos.py,sha256=SJ964_Wtpenn_-iR31XRCkcL4FPjOThbFCHNPtK64vc,5637
|
|
8
|
+
runmux/webhooks.py,sha256=ub3vHWnnyyo9lp5wrs9yNqBclOeLjA2DGTCuZyriMao,1093
|
|
9
|
+
runmux-0.1.0.dist-info/METADATA,sha256=J3XomkBlWxc0GqbJbNFlczLUpicxABX8NWuaqQtMPBc,5363
|
|
10
|
+
runmux-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
11
|
+
runmux-0.1.0.dist-info/RECORD,,
|