diffusr 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.
- diffusr/__init__.py +20 -0
- diffusr/_client.py +287 -0
- diffusr/_errors.py +36 -0
- diffusr/_verify.py +56 -0
- diffusr/py.typed +0 -0
- diffusr-0.1.0.dist-info/METADATA +135 -0
- diffusr-0.1.0.dist-info/RECORD +9 -0
- diffusr-0.1.0.dist-info/WHEEL +4 -0
- diffusr-0.1.0.dist-info/licenses/LICENSE +21 -0
diffusr/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""The diffusr API client. Stdlib only.
|
|
2
|
+
|
|
3
|
+
from diffusr import Diffusr
|
|
4
|
+
d = Diffusr() # reads DIFFUSR_KEY
|
|
5
|
+
quote = d.make_video(prompt="...", dry_run=True) # free
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from ._client import DEFAULT_BASE_URL, TERMINAL_STATUSES, Diffusr
|
|
9
|
+
from ._errors import DiffusrError
|
|
10
|
+
from ._verify import SIGNATURE_TOLERANCE_SEC, verify_callback
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"Diffusr",
|
|
14
|
+
"DiffusrError",
|
|
15
|
+
"verify_callback",
|
|
16
|
+
"DEFAULT_BASE_URL",
|
|
17
|
+
"TERMINAL_STATUSES",
|
|
18
|
+
"SIGNATURE_TOLERANCE_SEC",
|
|
19
|
+
]
|
|
20
|
+
__version__ = "0.1.0"
|
diffusr/_client.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import mimetypes
|
|
5
|
+
import os
|
|
6
|
+
import socket
|
|
7
|
+
import time
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import urllib.request
|
|
11
|
+
import uuid
|
|
12
|
+
from typing import Any, Callable, Dict, Mapping, Optional, Union
|
|
13
|
+
|
|
14
|
+
from ._errors import DiffusrError
|
|
15
|
+
from ._verify import verify_callback as _verify_callback
|
|
16
|
+
|
|
17
|
+
DEFAULT_BASE_URL = "https://app.diffusr.ai/api/v2"
|
|
18
|
+
SDK_VERSION = "0.1.0"
|
|
19
|
+
TERMINAL_STATUSES = ("SUCCEEDED", "FAILED", "CANCELED")
|
|
20
|
+
# Never sleep longer than this on one automatic retry; raise instead.
|
|
21
|
+
MAX_AUTO_RETRY_WAIT_SEC = 30
|
|
22
|
+
|
|
23
|
+
_CONTENT_TYPES = {
|
|
24
|
+
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp",
|
|
25
|
+
".gif": "image/gif", ".mp4": "video/mp4", ".mov": "video/quicktime", ".webm": "video/webm",
|
|
26
|
+
".mp3": "audio/mpeg", ".wav": "audio/wav", ".m4a": "audio/mp4",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Diffusr:
|
|
31
|
+
"""Client for the diffusr REST API (v2). Bodies and answers are the API's own snake_case JSON.
|
|
32
|
+
|
|
33
|
+
api_key dfsr_live_... or dfsr_test_... (free). Default: the DIFFUSR_KEY environment variable.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
api_key: Optional[str] = None,
|
|
39
|
+
base_url: Optional[str] = None,
|
|
40
|
+
*,
|
|
41
|
+
max_retries: int = 3,
|
|
42
|
+
retry_base: float = 0.5,
|
|
43
|
+
timeout: float = 60.0,
|
|
44
|
+
) -> None:
|
|
45
|
+
key = api_key or os.environ.get("DIFFUSR_KEY")
|
|
46
|
+
if not key:
|
|
47
|
+
raise DiffusrError(0, "MISSING_API_KEY", "Pass an API key or set DIFFUSR_KEY.")
|
|
48
|
+
self._key = key
|
|
49
|
+
self._base = (base_url or DEFAULT_BASE_URL).rstrip("/")
|
|
50
|
+
self._max_retries = max_retries
|
|
51
|
+
self._retry_base = retry_base
|
|
52
|
+
self._timeout = timeout
|
|
53
|
+
|
|
54
|
+
# ——— make calls (paid; dry_run=True is free) ———
|
|
55
|
+
|
|
56
|
+
def make_video(self, **body: Any) -> Dict[str, Any]:
|
|
57
|
+
return self._make("make_video", body)
|
|
58
|
+
|
|
59
|
+
def make_image(self, **body: Any) -> Dict[str, Any]:
|
|
60
|
+
return self._make("make_image", body)
|
|
61
|
+
|
|
62
|
+
def edit_image(self, **body: Any) -> Dict[str, Any]:
|
|
63
|
+
return self._make("edit_image", body)
|
|
64
|
+
|
|
65
|
+
def recast(self, **body: Any) -> Dict[str, Any]:
|
|
66
|
+
return self._make("recast", body)
|
|
67
|
+
|
|
68
|
+
def make_music(self, **body: Any) -> Dict[str, Any]:
|
|
69
|
+
return self._make("make_music", body)
|
|
70
|
+
|
|
71
|
+
def creator_video(self, **body: Any) -> Dict[str, Any]:
|
|
72
|
+
return self._make("creator_video", body)
|
|
73
|
+
|
|
74
|
+
def run_template(self, **body: Any) -> Dict[str, Any]:
|
|
75
|
+
"""Run one of diffusr's templates. One flat body: template="<id from list_templates()>",
|
|
76
|
+
plus the fields that template takes (mode, prompt, pictures, image_urls, product_image_url,
|
|
77
|
+
script, place, people, setup, resolution, dry_run, callback_url)."""
|
|
78
|
+
return self._make("run_template", body)
|
|
79
|
+
|
|
80
|
+
def _make(self, tool: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
|
81
|
+
# One Idempotency-Key per call, reused by every automatic retry.
|
|
82
|
+
key = body.pop("idempotency_key", None) or str(uuid.uuid4())
|
|
83
|
+
return self._request("POST", f"/{tool}", body=body, headers={"Idempotency-Key": key})
|
|
84
|
+
|
|
85
|
+
# ——— results ———
|
|
86
|
+
|
|
87
|
+
def get_result(self, id: str) -> Dict[str, Any]:
|
|
88
|
+
return self._request("GET", f"/results/{urllib.parse.quote(id, safe='')}")
|
|
89
|
+
|
|
90
|
+
def list_results(self, limit: Optional[int] = None) -> Dict[str, Any]:
|
|
91
|
+
return self._request("GET", "/results" + _query(limit=limit))
|
|
92
|
+
|
|
93
|
+
def cancel(self, id: str) -> Dict[str, Any]:
|
|
94
|
+
"""Stop queued work before it generates (that part is never charged).
|
|
95
|
+
|
|
96
|
+
A step that is already generating may still finish and be charged: check
|
|
97
|
+
result["charge"] on the answer. A finished job comes back unchanged.
|
|
98
|
+
"""
|
|
99
|
+
return self._request("POST", f"/results/{urllib.parse.quote(id, safe='')}/cancel", body={})
|
|
100
|
+
|
|
101
|
+
def wait_for(
|
|
102
|
+
self,
|
|
103
|
+
id: str,
|
|
104
|
+
timeout: float = 900.0,
|
|
105
|
+
interval: float = 3.0,
|
|
106
|
+
on_poll: Optional[Callable[[Dict[str, Any]], None]] = None,
|
|
107
|
+
) -> Dict[str, Any]:
|
|
108
|
+
"""Poll until the job is over and return the result (seconds, not ms).
|
|
109
|
+
|
|
110
|
+
A job that ends FAILED or CANCELED is returned too: check result["status"].
|
|
111
|
+
Raises DiffusrError(code="WAIT_TIMEOUT") if it is still running at the
|
|
112
|
+
deadline (the job itself keeps going).
|
|
113
|
+
"""
|
|
114
|
+
deadline = time.monotonic() + timeout
|
|
115
|
+
pause = interval
|
|
116
|
+
while True:
|
|
117
|
+
result = self.get_result(id)
|
|
118
|
+
if on_poll:
|
|
119
|
+
on_poll(result)
|
|
120
|
+
if result.get("status") in TERMINAL_STATUSES:
|
|
121
|
+
return result
|
|
122
|
+
left = deadline - time.monotonic()
|
|
123
|
+
if left <= 0:
|
|
124
|
+
break
|
|
125
|
+
time.sleep(min(pause, left))
|
|
126
|
+
pause = min(pause * 1.5, max(15.0, interval))
|
|
127
|
+
if time.monotonic() >= deadline:
|
|
128
|
+
break
|
|
129
|
+
raise DiffusrError(
|
|
130
|
+
0,
|
|
131
|
+
"WAIT_TIMEOUT",
|
|
132
|
+
f"Result {id} was still not finished after {int(timeout)} s. It keeps running: call wait_for or get_result again.",
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# ——— files ———
|
|
136
|
+
|
|
137
|
+
def upload_file(
|
|
138
|
+
self, file: Union[str, "os.PathLike[str]", bytes, bytearray], content_type: Optional[str] = None
|
|
139
|
+
) -> Dict[str, Any]:
|
|
140
|
+
"""Upload a local file (a path, or bytes) and get a file_url to pass to a tool.
|
|
141
|
+
|
|
142
|
+
Two steps: ask for a signed URL, PUT the exact bytes to it.
|
|
143
|
+
"""
|
|
144
|
+
if isinstance(file, (bytes, bytearray)):
|
|
145
|
+
data = bytes(file)
|
|
146
|
+
else:
|
|
147
|
+
path = os.fspath(file)
|
|
148
|
+
with open(path, "rb") as f:
|
|
149
|
+
data = f.read()
|
|
150
|
+
content_type = content_type or _CONTENT_TYPES.get(os.path.splitext(path)[1].lower()) or mimetypes.guess_type(path)[0]
|
|
151
|
+
if not content_type:
|
|
152
|
+
raise DiffusrError(0, "CONTENT_TYPE_REQUIRED", 'Pass content_type (e.g. "video/mp4"): it could not be worked out.')
|
|
153
|
+
ticket = self._request("POST", "/uploads", body={"content_type": content_type, "size_bytes": len(data)})
|
|
154
|
+
req = urllib.request.Request(ticket["upload_url"], data=data, method=ticket.get("method", "PUT"))
|
|
155
|
+
for k, v in ticket.get("headers", {}).items():
|
|
156
|
+
req.add_header(k, v)
|
|
157
|
+
try:
|
|
158
|
+
with urllib.request.urlopen(req, timeout=self._timeout):
|
|
159
|
+
pass
|
|
160
|
+
except urllib.error.HTTPError as e:
|
|
161
|
+
raise DiffusrError(e.code, "UPLOAD_FAILED", f"The upload PUT failed with HTTP {e.code}.") from None
|
|
162
|
+
except urllib.error.URLError as e:
|
|
163
|
+
raise DiffusrError(0, "NETWORK_ERROR", f"The upload PUT failed: {e.reason}") from None
|
|
164
|
+
return {"file_url": ticket["file_url"], "kind": ticket.get("kind")}
|
|
165
|
+
|
|
166
|
+
def upload_from_url(self, source_url: str) -> Dict[str, Any]:
|
|
167
|
+
"""Have us copy a public link in (images and short clips; use upload_file for big videos)."""
|
|
168
|
+
return self._request("POST", "/uploads", body={"source_url": source_url})
|
|
169
|
+
|
|
170
|
+
# ——— catalog ———
|
|
171
|
+
|
|
172
|
+
def list_models(self, kind: Optional[str] = None) -> Dict[str, Any]:
|
|
173
|
+
return self._request("GET", "/models" + _query(kind=kind))
|
|
174
|
+
|
|
175
|
+
def list_templates(self) -> Dict[str, Any]:
|
|
176
|
+
return self._request("GET", "/templates")
|
|
177
|
+
|
|
178
|
+
# ——— callbacks ———
|
|
179
|
+
|
|
180
|
+
def list_callbacks(self, limit: Optional[int] = None, cursor: Optional[str] = None) -> Dict[str, Any]:
|
|
181
|
+
return self._request("GET", "/callbacks" + _query(limit=limit, cursor=cursor))
|
|
182
|
+
|
|
183
|
+
def retry_callback(self, event_id: str) -> Dict[str, Any]:
|
|
184
|
+
"""One more try for a FAILED event (up to 3 by hand)."""
|
|
185
|
+
return self._request("POST", f"/callbacks/{urllib.parse.quote(event_id, safe='')}/retry", body={})
|
|
186
|
+
|
|
187
|
+
def get_callback_secret(self) -> Dict[str, Any]:
|
|
188
|
+
"""Your signing secret (whsec_...) for Diffusr.verify_callback."""
|
|
189
|
+
return self._request("GET", "/callbacks/secret")
|
|
190
|
+
|
|
191
|
+
def rotate_callback_secret(self) -> Dict[str, Any]:
|
|
192
|
+
return self._request("POST", "/callbacks/secret/rotate", body={})
|
|
193
|
+
|
|
194
|
+
@staticmethod
|
|
195
|
+
def verify_callback(
|
|
196
|
+
raw_body: Union[str, bytes],
|
|
197
|
+
headers: Mapping[str, str],
|
|
198
|
+
secret: str,
|
|
199
|
+
tolerance_sec: int = 300,
|
|
200
|
+
now: Optional[float] = None,
|
|
201
|
+
) -> bool:
|
|
202
|
+
"""Check a callback's signature (Standard Webhooks). Rejects a timestamp more than 5 minutes off."""
|
|
203
|
+
return _verify_callback(raw_body, headers, secret, tolerance_sec, now)
|
|
204
|
+
|
|
205
|
+
# ——— plumbing ———
|
|
206
|
+
|
|
207
|
+
def _backoff(self, attempt: int) -> float:
|
|
208
|
+
return self._retry_base * (2**attempt)
|
|
209
|
+
|
|
210
|
+
def _request(
|
|
211
|
+
self, method: str, path: str, body: Optional[Dict[str, Any]] = None, headers: Optional[Dict[str, str]] = None
|
|
212
|
+
) -> Dict[str, Any]:
|
|
213
|
+
hdrs = {
|
|
214
|
+
"Authorization": f"Bearer {self._key}",
|
|
215
|
+
"Accept": "application/json",
|
|
216
|
+
"User-Agent": f"diffusr-python/{SDK_VERSION}",
|
|
217
|
+
}
|
|
218
|
+
data = None
|
|
219
|
+
if body is not None:
|
|
220
|
+
hdrs["Content-Type"] = "application/json"
|
|
221
|
+
data = json.dumps(body).encode("utf-8")
|
|
222
|
+
hdrs.update(headers or {})
|
|
223
|
+
|
|
224
|
+
attempt = 0
|
|
225
|
+
while True:
|
|
226
|
+
req = urllib.request.Request(self._base + path, data=data, method=method, headers=hdrs)
|
|
227
|
+
try:
|
|
228
|
+
with urllib.request.urlopen(req, timeout=self._timeout) as res:
|
|
229
|
+
text = res.read().decode("utf-8")
|
|
230
|
+
return json.loads(text) if text else {}
|
|
231
|
+
except urllib.error.HTTPError as e:
|
|
232
|
+
err = _to_error(e)
|
|
233
|
+
wait = err.retry_after if err.retry_after is not None else self._backoff(attempt)
|
|
234
|
+
retryable = e.code in (429, 503) or (method == "GET" and e.code in (500, 502, 504))
|
|
235
|
+
if retryable and attempt < self._max_retries and wait <= MAX_AUTO_RETRY_WAIT_SEC:
|
|
236
|
+
time.sleep(wait)
|
|
237
|
+
attempt += 1
|
|
238
|
+
continue
|
|
239
|
+
raise err from None
|
|
240
|
+
except (urllib.error.URLError, OSError) as e:
|
|
241
|
+
# OSError covers socket.timeout (a separate class before 3.10), TimeoutError, ConnectionError.
|
|
242
|
+
if attempt < self._max_retries:
|
|
243
|
+
time.sleep(self._backoff(attempt))
|
|
244
|
+
attempt += 1
|
|
245
|
+
continue
|
|
246
|
+
reason = getattr(e, "reason", e)
|
|
247
|
+
if _is_timeout(e) or _is_timeout(reason):
|
|
248
|
+
raise DiffusrError(0, "TIMEOUT", f"diffusr did not answer within {self._timeout:g} s.") from None
|
|
249
|
+
raise DiffusrError(0, "NETWORK_ERROR", f"Could not reach diffusr: {reason}") from None
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _is_timeout(e: Any) -> bool:
|
|
253
|
+
return isinstance(e, (socket.timeout, TimeoutError))
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _query(**params: Any) -> str:
|
|
257
|
+
q = urllib.parse.urlencode({k: v for k, v in params.items() if v is not None})
|
|
258
|
+
return f"?{q}" if q else ""
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _to_error(e: urllib.error.HTTPError) -> DiffusrError:
|
|
262
|
+
text = ""
|
|
263
|
+
try:
|
|
264
|
+
text = e.read().decode("utf-8", "replace")
|
|
265
|
+
except Exception:
|
|
266
|
+
pass
|
|
267
|
+
body: Optional[Dict[str, Any]] = None
|
|
268
|
+
try:
|
|
269
|
+
parsed = json.loads(text)
|
|
270
|
+
if isinstance(parsed, dict):
|
|
271
|
+
body = parsed
|
|
272
|
+
except ValueError:
|
|
273
|
+
pass
|
|
274
|
+
retry_after: Optional[float] = None
|
|
275
|
+
header = e.headers.get("Retry-After") if e.headers else None
|
|
276
|
+
if header is not None:
|
|
277
|
+
try:
|
|
278
|
+
retry_after = float(header)
|
|
279
|
+
except ValueError:
|
|
280
|
+
pass
|
|
281
|
+
if retry_after is None and body and isinstance(body.get("retry_after_seconds"), (int, float)):
|
|
282
|
+
retry_after = float(body["retry_after_seconds"])
|
|
283
|
+
message = body["error"] if body and isinstance(body.get("error"), str) else (text[:300] or f"HTTP {e.code}")
|
|
284
|
+
code = body["code"] if body and isinstance(body.get("code"), str) else f"HTTP_{e.code}"
|
|
285
|
+
request_id = body.get("request_id") if body and isinstance(body.get("request_id"), str) else None
|
|
286
|
+
request_id = request_id or (e.headers.get("X-Request-Id") if e.headers else None)
|
|
287
|
+
return DiffusrError(e.code, code, message, request_id, retry_after, body)
|
diffusr/_errors.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DiffusrError(Exception):
|
|
7
|
+
"""Every non-2xx answer (and a few client-side failures) raises this.
|
|
8
|
+
|
|
9
|
+
status HTTP status; 0 when no answer came (network error, wait timeout)
|
|
10
|
+
code the API's machine code, e.g. "RATE_LIMITED"
|
|
11
|
+
message the API's plain-words error
|
|
12
|
+
request_id quote it to support
|
|
13
|
+
retry_after seconds to wait before trying again, when the API said so
|
|
14
|
+
body the whole JSON error body (field, issues, budget...)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
status: int,
|
|
20
|
+
code: str,
|
|
21
|
+
message: str,
|
|
22
|
+
request_id: Optional[str] = None,
|
|
23
|
+
retry_after: Optional[float] = None,
|
|
24
|
+
body: Optional[Dict[str, Any]] = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
self.status = status
|
|
28
|
+
self.code = code
|
|
29
|
+
self.message = message
|
|
30
|
+
self.request_id = request_id
|
|
31
|
+
self.retry_after = retry_after
|
|
32
|
+
self.body = body
|
|
33
|
+
|
|
34
|
+
def __str__(self) -> str:
|
|
35
|
+
rid = f" (request_id {self.request_id})" if self.request_id else ""
|
|
36
|
+
return f"[{self.code}] {self.message}{rid}"
|
diffusr/_verify.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import binascii
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import time
|
|
8
|
+
from typing import Mapping, Optional, Union
|
|
9
|
+
|
|
10
|
+
SIGNATURE_TOLERANCE_SEC = 300
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _header(headers: Mapping[str, str], name: str) -> Optional[str]:
|
|
14
|
+
for k, v in headers.items():
|
|
15
|
+
if k.lower() == name:
|
|
16
|
+
return v if isinstance(v, str) else " ".join(v)
|
|
17
|
+
return None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def verify_callback(
|
|
21
|
+
raw_body: Union[str, bytes],
|
|
22
|
+
headers: Mapping[str, str],
|
|
23
|
+
secret: str,
|
|
24
|
+
tolerance_sec: int = SIGNATURE_TOLERANCE_SEC,
|
|
25
|
+
now: Optional[float] = None,
|
|
26
|
+
) -> bool:
|
|
27
|
+
"""Check a diffusr callback (Standard Webhooks).
|
|
28
|
+
|
|
29
|
+
raw_body is the request body exactly as received, before JSON parsing.
|
|
30
|
+
Returns False for a missing header, a bad or stale signature; never raises.
|
|
31
|
+
"""
|
|
32
|
+
try:
|
|
33
|
+
msg_id = _header(headers, "webhook-id")
|
|
34
|
+
timestamp = _header(headers, "webhook-timestamp")
|
|
35
|
+
signature = _header(headers, "webhook-signature")
|
|
36
|
+
if not msg_id or not timestamp or not signature or not timestamp.isdigit():
|
|
37
|
+
return False
|
|
38
|
+
current = time.time() if now is None else now
|
|
39
|
+
if abs(int(current) - int(timestamp)) > tolerance_sec:
|
|
40
|
+
return False
|
|
41
|
+
body = raw_body if isinstance(raw_body, (bytes, bytearray)) else raw_body.encode("utf-8")
|
|
42
|
+
key = base64.b64decode(secret[6:] if secret.startswith("whsec_") else secret)
|
|
43
|
+
expected = hmac.new(key, f"{msg_id}.{timestamp}.".encode("utf-8") + bytes(body), hashlib.sha256).digest()
|
|
44
|
+
ok = False
|
|
45
|
+
for part in signature.split(" "):
|
|
46
|
+
if not part.startswith("v1,"):
|
|
47
|
+
continue
|
|
48
|
+
try:
|
|
49
|
+
given = base64.b64decode(part[3:], validate=True)
|
|
50
|
+
except (binascii.Error, ValueError):
|
|
51
|
+
continue
|
|
52
|
+
if hmac.compare_digest(given, expected):
|
|
53
|
+
ok = True
|
|
54
|
+
return ok
|
|
55
|
+
except Exception:
|
|
56
|
+
return False
|
diffusr/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: diffusr
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The diffusr API client: make videos, images and music from code. Free price checks, idempotent starts, wait_for, signed callbacks.
|
|
5
|
+
Project-URL: Homepage, https://diffusr.ai
|
|
6
|
+
Project-URL: Documentation, https://diffusr.ai/docs
|
|
7
|
+
License: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: ai,api,diffusr,image,video
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Typing :: Typed
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# diffusr
|
|
17
|
+
|
|
18
|
+
The diffusr API client for Python 3.9+. Make videos, images and music from
|
|
19
|
+
code. Standard library only. Bodies and answers are the API's own snake_case
|
|
20
|
+
JSON (plain dicts).
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
pip install diffusr
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Quickstart
|
|
27
|
+
|
|
28
|
+
Use a **test key** (`dfsr_test_…`) while you build: prices are quoted exactly,
|
|
29
|
+
nothing is charged, no model runs, and the result is a placeholder file a few
|
|
30
|
+
seconds later. Swap in a live key (`dfsr_live_…`) when you are ready to spend.
|
|
31
|
+
Mint keys on the Developers page in the app.
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from diffusr import Diffusr
|
|
35
|
+
|
|
36
|
+
diffusr = Diffusr() # reads DIFFUSR_KEY
|
|
37
|
+
|
|
38
|
+
# 1. Ask the price. Free: nothing is made, nothing is spent.
|
|
39
|
+
quote = diffusr.make_video(
|
|
40
|
+
prompt="A can of sparkling water on a wet street, slow push-in, rain",
|
|
41
|
+
duration_sec=5,
|
|
42
|
+
dry_run=True,
|
|
43
|
+
)
|
|
44
|
+
print(quote["price"]) # {'credits': 63, 'usd': '0.6300', ...}
|
|
45
|
+
|
|
46
|
+
# 2. Make it. An Idempotency-Key is added for you and reused on retries,
|
|
47
|
+
# so a dropped connection never pays twice.
|
|
48
|
+
job = diffusr.make_video(
|
|
49
|
+
prompt="A can of sparkling water on a wet street, slow push-in, rain",
|
|
50
|
+
duration_sec=5,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# 3. Wait for it (polls with backoff; raises WAIT_TIMEOUT if it takes too long).
|
|
54
|
+
result = diffusr.wait_for(job["id"], timeout=600)
|
|
55
|
+
if result["status"] == "SUCCEEDED":
|
|
56
|
+
print(result["output"]["url"])
|
|
57
|
+
else:
|
|
58
|
+
print(result["status"], result.get("error"))
|
|
59
|
+
print(result["charge"]) # quoted_credits, charged_credits, final
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Callbacks instead of polling
|
|
63
|
+
|
|
64
|
+
Pass `callback_url` (https, public) and we POST one signed event when the job
|
|
65
|
+
ends. Verify it with your signing secret (`diffusr.get_callback_secret()["secret"]`):
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from diffusr import Diffusr
|
|
69
|
+
|
|
70
|
+
# Flask: use the RAW body, not the parsed one.
|
|
71
|
+
@app.post("/diffusr")
|
|
72
|
+
def hook():
|
|
73
|
+
raw = request.get_data(as_text=True)
|
|
74
|
+
if not Diffusr.verify_callback(raw, request.headers, os.environ["DIFFUSR_WHSEC"]):
|
|
75
|
+
return "", 400
|
|
76
|
+
event = json.loads(raw) # {id, type: "result.finished", data: <result>}
|
|
77
|
+
return "", 200 # any 2xx; drop repeats of event["id"]
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`verify_callback` implements [Standard Webhooks](https://www.standardwebhooks.com):
|
|
81
|
+
constant-time compare, two signatures after a secret rotation, a 5-minute
|
|
82
|
+
timestamp window, `whsec_` prefix handled.
|
|
83
|
+
|
|
84
|
+
## Methods
|
|
85
|
+
|
|
86
|
+
| Method | REST |
|
|
87
|
+
|---|---|
|
|
88
|
+
| `make_video`, `make_image`, `edit_image`, `recast`, `make_music`, `creator_video`, `run_template` `(**body)` | `POST /{tool}` |
|
|
89
|
+
| `get_result(id)`, `list_results(limit=)`, `cancel(id)` (stops queued work; a step already generating may finish and be charged: check `result["charge"]`) | `GET /results/{id}`, `GET /results`, `POST /results/{id}/cancel` |
|
|
90
|
+
| `wait_for(id, timeout=, interval=, on_poll=)` | polls `GET /results/{id}` |
|
|
91
|
+
| `upload_file(path_or_bytes, content_type=)`, `upload_from_url(url)` | `POST /uploads` + signed `PUT` |
|
|
92
|
+
| `list_models(kind=)`, `list_templates()` | `GET /models`, `GET /templates` |
|
|
93
|
+
| `list_callbacks(limit=, cursor=)`, `retry_callback(event_id)`, `get_callback_secret()`, `rotate_callback_secret()` | `/callbacks…` |
|
|
94
|
+
| `Diffusr.verify_callback(raw_body, headers, secret)` | |
|
|
95
|
+
|
|
96
|
+
`run_template` takes one flat body: `template="<id from list_templates()>"` plus the fields that
|
|
97
|
+
template takes (`mode`, `prompt`, `pictures`, `image_urls`, `product_image_url`, `script`, `place`,
|
|
98
|
+
`people`, `setup`, `resolution`):
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
diffusr.run_template(template="tpl-ugc-talking-head", script="Hi!", people=[{"name": "Mia", "image_url": "https://…"}], dry_run=True)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`dry_run=True` on any make call answers with the price only. Pass
|
|
105
|
+
`idempotency_key="…"` to use your own key.
|
|
106
|
+
|
|
107
|
+
## Retries and errors
|
|
108
|
+
|
|
109
|
+
- Every request times out after 60 s (`Diffusr(timeout=...)`) with a `TIMEOUT` error.
|
|
110
|
+
- `GET`s (and so `wait_for`) retry on 429, 500, 502, 503, 504, network errors
|
|
111
|
+
and timeouts with backoff, honouring `Retry-After` (waits over 30 s are not
|
|
112
|
+
slept: the error is raised).
|
|
113
|
+
- Make calls retry on 429, 503, network errors and timeouts only, **with the
|
|
114
|
+
same Idempotency-Key**.
|
|
115
|
+
- Every failure is a `DiffusrError`:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
from diffusr import DiffusrError
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
diffusr.make_video(prompt="…")
|
|
122
|
+
except DiffusrError as e:
|
|
123
|
+
print(e.status, e.code, e.message, e.request_id, e.retry_after)
|
|
124
|
+
# e.body has the extras: issues, field, budget…
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Codes are the API's (`RATE_LIMITED`, `BUDGET_EXCEEDED`, `INSUFFICIENT_CREDITS`…).
|
|
128
|
+
The SDK adds `NETWORK_ERROR`, `TIMEOUT`, `WAIT_TIMEOUT`, `UPLOAD_FAILED`,
|
|
129
|
+
`CONTENT_TYPE_REQUIRED` and `MISSING_API_KEY`. Handle codes you don't know.
|
|
130
|
+
|
|
131
|
+
## Docs
|
|
132
|
+
|
|
133
|
+
Full API reference, prices and agent guides: https://diffusr.ai/docs
|
|
134
|
+
|
|
135
|
+
MIT licensed.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
diffusr/__init__.py,sha256=GJu0H3cqXBbB8VunnZqrzq0rqAGhOGSlEpP0i1Fasvo,516
|
|
2
|
+
diffusr/_client.py,sha256=MMyVDdmff1nIQSuiFKf_9c0sJDITUSY-fkNQjodgKyI,12005
|
|
3
|
+
diffusr/_errors.py,sha256=YWrBDO9lzEqHgckTc66uLcpETfvbWmeyhmB2rFVJkzc,1184
|
|
4
|
+
diffusr/_verify.py,sha256=d5pNG-qf3CtXWuI5Ejm614kB96o0shMPE9xa34s_ZgM,1954
|
|
5
|
+
diffusr/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
diffusr-0.1.0.dist-info/METADATA,sha256=XzZOenx6fc7hiOEjMPj-vlsA6LzXjNtfus3xZDAC76A,5151
|
|
7
|
+
diffusr-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
8
|
+
diffusr-0.1.0.dist-info/licenses/LICENSE,sha256=1l1mtdgkeKxmibt4kNGlmLRfZhUQWNx-rJB0lQD_dWY,1064
|
|
9
|
+
diffusr-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 diffusr
|
|
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.
|