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 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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any