cutpro 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.
- cutpro/__init__.py +44 -0
- cutpro/_client.py +430 -0
- cutpro/py.typed +0 -0
- cutpro/types.py +212 -0
- cutpro-0.1.0.dist-info/METADATA +263 -0
- cutpro-0.1.0.dist-info/RECORD +8 -0
- cutpro-0.1.0.dist-info/WHEEL +4 -0
- cutpro-0.1.0.dist-info/licenses/LICENSE +21 -0
cutpro/__init__.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from ._client import AsyncCutPro, CutPro, CutProError, Transport, __version__, proxy_request, urllib_transport, verify_webhook
|
|
2
|
+
from .types import (
|
|
3
|
+
AspectRatio,
|
|
4
|
+
CreateGenerationRequest,
|
|
5
|
+
Generation,
|
|
6
|
+
GenerationFile,
|
|
7
|
+
GenerationMode,
|
|
8
|
+
GenerationOutput,
|
|
9
|
+
GenerationRequest,
|
|
10
|
+
Media,
|
|
11
|
+
Model,
|
|
12
|
+
ModelList,
|
|
13
|
+
PriceList,
|
|
14
|
+
PublicModel,
|
|
15
|
+
PublicPrice,
|
|
16
|
+
Quote,
|
|
17
|
+
Voice,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"AspectRatio",
|
|
22
|
+
"AsyncCutPro",
|
|
23
|
+
"CreateGenerationRequest",
|
|
24
|
+
"CutPro",
|
|
25
|
+
"CutProError",
|
|
26
|
+
"Generation",
|
|
27
|
+
"GenerationFile",
|
|
28
|
+
"GenerationMode",
|
|
29
|
+
"GenerationOutput",
|
|
30
|
+
"GenerationRequest",
|
|
31
|
+
"Media",
|
|
32
|
+
"Model",
|
|
33
|
+
"ModelList",
|
|
34
|
+
"PriceList",
|
|
35
|
+
"PublicModel",
|
|
36
|
+
"PublicPrice",
|
|
37
|
+
"Quote",
|
|
38
|
+
"Transport",
|
|
39
|
+
"Voice",
|
|
40
|
+
"__version__",
|
|
41
|
+
"proxy_request",
|
|
42
|
+
"urllib_transport",
|
|
43
|
+
"verify_webhook",
|
|
44
|
+
]
|
cutpro/_client.py
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import hashlib
|
|
5
|
+
import hmac
|
|
6
|
+
import json
|
|
7
|
+
import mimetypes
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
import time
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.parse
|
|
14
|
+
import urllib.request
|
|
15
|
+
import uuid
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import TYPE_CHECKING, Any, Callable, Dict, Mapping, Optional, Tuple, Union
|
|
18
|
+
|
|
19
|
+
from .types import (
|
|
20
|
+
CreateGenerationRequest,
|
|
21
|
+
DownloadLink,
|
|
22
|
+
Generation,
|
|
23
|
+
GenerationRequest,
|
|
24
|
+
Media,
|
|
25
|
+
ModelEndpoint,
|
|
26
|
+
ModelKind,
|
|
27
|
+
ModelList,
|
|
28
|
+
PriceList,
|
|
29
|
+
Quote,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from typing_extensions import Unpack
|
|
34
|
+
|
|
35
|
+
__version__ = "0.1.0"
|
|
36
|
+
|
|
37
|
+
API_ORIGIN = "https://api.cut.pro"
|
|
38
|
+
API_PATH = "/api/v1"
|
|
39
|
+
PRICES_PATH = "/public/generations/models"
|
|
40
|
+
MAX_SERVER_WAIT_SECONDS = 60
|
|
41
|
+
DEFAULT_WAIT_TIMEOUT = 600.0
|
|
42
|
+
# Longer than the 60 seconds a create or a get may be held by the server.
|
|
43
|
+
DEFAULT_TIMEOUT = 90.0
|
|
44
|
+
MAX_RETRIES = 2
|
|
45
|
+
RETRY_BASE_SECONDS = 0.5
|
|
46
|
+
RETRYABLE = frozenset({408, 425, 429, 500, 502, 503, 504})
|
|
47
|
+
WEBHOOK_TOLERANCE_SECONDS = 300
|
|
48
|
+
DOWNLOAD_CHUNK_BYTES = 1 << 20
|
|
49
|
+
# The proxy only forwards JSON requests; uploads go from your server with the client.
|
|
50
|
+
MAX_PROXY_BODY_BYTES = 1 << 20
|
|
51
|
+
LOCAL_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
|
|
52
|
+
# What `proxy_request()` forwards: everything a browser app needs to price, start and follow a generation, nothing that reads the account.
|
|
53
|
+
PROXIED = (
|
|
54
|
+
("GET", re.compile(r"^/models(/featured)?$")),
|
|
55
|
+
("GET", re.compile(r"^/prices$")),
|
|
56
|
+
("POST", re.compile(r"^/generations(/quote)?$")),
|
|
57
|
+
("GET", re.compile(r"^/generations/[\w-]+$")),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
Transport = Callable[[str, str, Dict[str, str], Optional[bytes], float], Tuple[int, Mapping[str, str], bytes]]
|
|
61
|
+
"""(method, url, headers, body, timeout) -> (status, response headers, body). Swap it to use another HTTP library or to stub the API in tests."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
65
|
+
# A redirect would carry the key to wherever it points, so it is answered as the error it is instead.
|
|
66
|
+
def redirect_request(self, *args: Any, **kwargs: Any) -> None:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
_opener = urllib.request.build_opener(_NoRedirect)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def urllib_transport(method: str, url: str, headers: Dict[str, str], body: Optional[bytes], timeout: float) -> Tuple[int, Mapping[str, str], bytes]:
|
|
74
|
+
request = urllib.request.Request(url, data=body, headers=headers, method=method)
|
|
75
|
+
try:
|
|
76
|
+
with _opener.open(request, timeout=timeout) as response:
|
|
77
|
+
return int(response.status), dict(response.headers), response.read()
|
|
78
|
+
except urllib.error.HTTPError as error:
|
|
79
|
+
return error.code, dict(error.headers or {}), error.read()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class CutProError(Exception):
|
|
83
|
+
"""An API error: `code` is stable (switch on it), `status` is the HTTP status, or 0 when the client raised it."""
|
|
84
|
+
|
|
85
|
+
def __init__(self, code: str, status: int, extra: Optional[Dict[str, Any]] = None) -> None:
|
|
86
|
+
super().__init__(f"{status} {code}" if status else code)
|
|
87
|
+
self.code = code
|
|
88
|
+
self.status = status
|
|
89
|
+
self.extra = extra
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _secure_base(base_url: str) -> str:
|
|
93
|
+
parsed = urllib.parse.urlsplit(base_url)
|
|
94
|
+
if parsed.scheme != "https" and not (parsed.scheme == "http" and parsed.hostname in LOCAL_HOSTS):
|
|
95
|
+
raise CutProError("INSECURE_BASE_URL", 0, {"base_url": base_url})
|
|
96
|
+
return base_url.rstrip("/")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _retry_after(headers: Mapping[str, str], attempt: int) -> float:
|
|
100
|
+
value = next((v for k, v in headers.items() if k.lower() == "retry-after"), "")
|
|
101
|
+
try:
|
|
102
|
+
return min(max(float(value), 0.0), 30.0)
|
|
103
|
+
except ValueError:
|
|
104
|
+
return RETRY_BASE_SECONDS * 2**attempt
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _quote_id(id: str) -> str:
|
|
108
|
+
return urllib.parse.quote(id, safe="")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class CutPro:
|
|
112
|
+
"""Synchronous client. Thread safe: it keeps no connection state."""
|
|
113
|
+
|
|
114
|
+
def __init__(
|
|
115
|
+
self,
|
|
116
|
+
api_key: Optional[str] = None,
|
|
117
|
+
*,
|
|
118
|
+
base_url: Optional[str] = None,
|
|
119
|
+
workspace_id: Optional[str] = None,
|
|
120
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
121
|
+
max_retries: int = MAX_RETRIES,
|
|
122
|
+
transport: Optional[Transport] = None,
|
|
123
|
+
) -> None:
|
|
124
|
+
self._api_key = api_key if api_key is not None else os.environ.get("CUTPRO_API_KEY")
|
|
125
|
+
self._direct = base_url is None
|
|
126
|
+
self.base_url = _secure_base(base_url or f"{API_ORIGIN}{API_PATH}")
|
|
127
|
+
self.workspace_id = workspace_id
|
|
128
|
+
self.timeout = timeout
|
|
129
|
+
self.max_retries = max_retries
|
|
130
|
+
self.transport = transport or urllib_transport
|
|
131
|
+
self.models = Models(self)
|
|
132
|
+
self.generations = Generations(self)
|
|
133
|
+
self.media = MediaLibrary(self)
|
|
134
|
+
|
|
135
|
+
def __repr__(self) -> str:
|
|
136
|
+
return f"CutPro(base_url={self.base_url!r}, api_key={'set' if self._api_key else 'missing'})"
|
|
137
|
+
|
|
138
|
+
def _request(
|
|
139
|
+
self,
|
|
140
|
+
method: str,
|
|
141
|
+
path: str,
|
|
142
|
+
*,
|
|
143
|
+
query: Optional[Mapping[str, Union[str, int, None]]] = None,
|
|
144
|
+
json_body: Optional[Mapping[str, Any]] = None,
|
|
145
|
+
body: Optional[bytes] = None,
|
|
146
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
147
|
+
auth: bool = True,
|
|
148
|
+
retry: bool = True,
|
|
149
|
+
timeout: Optional[float] = None,
|
|
150
|
+
) -> Any:
|
|
151
|
+
# The public price list lives outside /api/v1; a proxy serves it at /prices.
|
|
152
|
+
url = f"{API_ORIGIN}{PRICES_PATH}" if path == "/prices" and self._direct else f"{self.base_url}{path}"
|
|
153
|
+
params = {key: str(value) for key, value in (query or {}).items() if value is not None}
|
|
154
|
+
if params:
|
|
155
|
+
url = f"{url}?{urllib.parse.urlencode(params)}"
|
|
156
|
+
sent = {"Accept": "application/json", "User-Agent": f"cutpro-python/{__version__}", **(headers or {})}
|
|
157
|
+
if auth and self._api_key:
|
|
158
|
+
sent["X-Api-Key"] = self._api_key
|
|
159
|
+
if auth and self.workspace_id:
|
|
160
|
+
sent["X-Workspace-Id"] = self.workspace_id
|
|
161
|
+
if json_body is not None:
|
|
162
|
+
sent["Content-Type"] = "application/json"
|
|
163
|
+
body = json.dumps(json_body).encode()
|
|
164
|
+
|
|
165
|
+
attempt = 0
|
|
166
|
+
while True:
|
|
167
|
+
try:
|
|
168
|
+
status, response_headers, raw = self.transport(method, url, sent, body, timeout or self.timeout)
|
|
169
|
+
except (OSError, TimeoutError):
|
|
170
|
+
if not retry or attempt >= self.max_retries:
|
|
171
|
+
raise
|
|
172
|
+
time.sleep(RETRY_BASE_SECONDS * 2**attempt)
|
|
173
|
+
attempt += 1
|
|
174
|
+
continue
|
|
175
|
+
if status in RETRYABLE and retry and attempt < self.max_retries:
|
|
176
|
+
time.sleep(_retry_after(response_headers, attempt))
|
|
177
|
+
attempt += 1
|
|
178
|
+
continue
|
|
179
|
+
break
|
|
180
|
+
try:
|
|
181
|
+
payload = json.loads(raw) if raw else None
|
|
182
|
+
except ValueError:
|
|
183
|
+
payload = None
|
|
184
|
+
if status >= 400:
|
|
185
|
+
error = payload if isinstance(payload, dict) else {}
|
|
186
|
+
code = error.get("code")
|
|
187
|
+
raise CutProError(code if isinstance(code, str) else "UNEXPECTED_RESPONSE", status, error.get("extra"))
|
|
188
|
+
return payload
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class Models:
|
|
192
|
+
def __init__(self, client: CutPro) -> None:
|
|
193
|
+
self._client = client
|
|
194
|
+
|
|
195
|
+
def list(self, *, kind: Optional[ModelKind] = None, endpoint: Optional[ModelEndpoint] = None, q: Optional[str] = None, id: Optional[str] = None) -> ModelList:
|
|
196
|
+
"""Every model with its modes, resolutions, seconds, voices and prices in credits, dollars and reais."""
|
|
197
|
+
return self._client._request("GET", "/models", query={"kind": kind, "endpoint": endpoint, "q": q, "id": id})
|
|
198
|
+
|
|
199
|
+
def featured(self, *, kind: Optional[ModelKind] = None, endpoint: Optional[ModelEndpoint] = None) -> ModelList:
|
|
200
|
+
"""Only the strongest models of each kind."""
|
|
201
|
+
return self._client._request("GET", "/models/featured", query={"kind": kind, "endpoint": endpoint})
|
|
202
|
+
|
|
203
|
+
def prices(self) -> PriceList:
|
|
204
|
+
"""Public price list of the image, video and voice models in credits, dollars and reais. Needs no key."""
|
|
205
|
+
return self._client._request("GET", "/prices", auth=False)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class Generations:
|
|
209
|
+
def __init__(self, client: CutPro) -> None:
|
|
210
|
+
self._client = client
|
|
211
|
+
|
|
212
|
+
def quote(self, **request: Unpack[GenerationRequest]) -> Quote:
|
|
213
|
+
"""The exact total of a request, in credits. Free, generates nothing."""
|
|
214
|
+
return self._client._request("POST", "/generations/quote", json_body=request)
|
|
215
|
+
|
|
216
|
+
def create(self, *, idempotency_key: Optional[str] = None, wait: Optional[int] = None, **request: Unpack[CreateGenerationRequest]) -> Generation:
|
|
217
|
+
"""Starts a generation. Without `credits`, it quotes first and agrees to that total. Safe to retry: the idempotency key never charges twice."""
|
|
218
|
+
credits = request.pop("credits", None)
|
|
219
|
+
webhook_url = request.pop("webhook_url", None)
|
|
220
|
+
if credits is None:
|
|
221
|
+
credits = int(self._client._request("POST", "/generations/quote", json_body=request)["credits"])
|
|
222
|
+
body: Dict[str, Any] = {**request, "credits": credits}
|
|
223
|
+
if webhook_url:
|
|
224
|
+
body["webhook_url"] = webhook_url
|
|
225
|
+
return self._client._request(
|
|
226
|
+
"POST",
|
|
227
|
+
"/generations",
|
|
228
|
+
query={"wait": wait},
|
|
229
|
+
json_body=body,
|
|
230
|
+
headers={"Idempotency-Key": idempotency_key or str(uuid.uuid4())},
|
|
231
|
+
timeout=self._client.timeout + (wait or 0),
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
def get(self, id: str, *, wait: Optional[int] = None) -> Generation:
|
|
235
|
+
return self._client._request("GET", f"/generations/{_quote_id(id)}", query={"wait": wait}, timeout=self._client.timeout + (wait or 0))
|
|
236
|
+
|
|
237
|
+
def wait(self, id: str, *, interval: float = 1.0, timeout: float = DEFAULT_WAIT_TIMEOUT) -> Generation:
|
|
238
|
+
"""Polls until the generation is `ready` or `failed` and returns it either way. Each poll is held by the server for up to 60 seconds."""
|
|
239
|
+
deadline = time.monotonic() + timeout
|
|
240
|
+
while True:
|
|
241
|
+
generation = self.get(id, wait=min(MAX_SERVER_WAIT_SECONDS, max(0, int(deadline - time.monotonic()))))
|
|
242
|
+
if generation["status"] != "processing":
|
|
243
|
+
return generation
|
|
244
|
+
if time.monotonic() + interval >= deadline:
|
|
245
|
+
raise CutProError("WAIT_TIMEOUT", 0, {"id": id})
|
|
246
|
+
time.sleep(interval)
|
|
247
|
+
|
|
248
|
+
def generate(self, *, idempotency_key: Optional[str] = None, interval: float = 1.0, timeout: float = DEFAULT_WAIT_TIMEOUT, **request: Unpack[CreateGenerationRequest]) -> Generation:
|
|
249
|
+
"""Quotes, creates and waits. Raises `CutProError` with the generation's `error_code` when it fails."""
|
|
250
|
+
started = self.create(idempotency_key=idempotency_key, wait=min(MAX_SERVER_WAIT_SECONDS, int(timeout)), **request)
|
|
251
|
+
generation = self.wait(started["id"], interval=interval, timeout=timeout) if started["status"] == "processing" else started
|
|
252
|
+
if generation["status"] == "failed":
|
|
253
|
+
raise CutProError(generation["error_code"] or "GENERATION_FAILED", 0, {"id": generation["id"]})
|
|
254
|
+
return generation
|
|
255
|
+
|
|
256
|
+
def download(self, id: str) -> DownloadLink:
|
|
257
|
+
"""Full quality link, valid for 6 hours. After downloading, the credits can no longer be asked back."""
|
|
258
|
+
return self._client._request("GET", f"/generations/{_quote_id(id)}/download")
|
|
259
|
+
|
|
260
|
+
def download_to(self, id: str, path: Union[str, Path]) -> Path:
|
|
261
|
+
"""Streams the full quality file to `path` without holding it in memory, and returns the path."""
|
|
262
|
+
url = self.download(id)["url"]
|
|
263
|
+
if urllib.parse.urlsplit(url).scheme != "https":
|
|
264
|
+
raise CutProError("INSECURE_DOWNLOAD_URL", 0, {"id": id})
|
|
265
|
+
target = Path(path)
|
|
266
|
+
partial = target.with_name(f"{target.name}.part")
|
|
267
|
+
with urllib.request.urlopen(url, timeout=self._client.timeout) as response, partial.open("wb") as file:
|
|
268
|
+
shutil.copyfileobj(response, file, DOWNLOAD_CHUNK_BYTES)
|
|
269
|
+
partial.replace(target)
|
|
270
|
+
return target
|
|
271
|
+
|
|
272
|
+
def webhook_secret(self) -> str:
|
|
273
|
+
"""The secret that signs this key's webhooks, for `verify_webhook()`. Keep it on the server."""
|
|
274
|
+
return str(self._client._request("GET", "/generations/webhook-secret")["secret"])
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class MediaLibrary:
|
|
278
|
+
def __init__(self, client: CutPro) -> None:
|
|
279
|
+
self._client = client
|
|
280
|
+
|
|
281
|
+
def upload(
|
|
282
|
+
self,
|
|
283
|
+
data: Union[bytes, str, Path, None] = None,
|
|
284
|
+
*,
|
|
285
|
+
filename: Optional[str] = None,
|
|
286
|
+
content_type: Optional[str] = None,
|
|
287
|
+
url: Optional[str] = None,
|
|
288
|
+
) -> Media:
|
|
289
|
+
"""Uploads an image, video or sound (bytes or a file path, up to 100 MB), or reads one from a public `url`, for `image_ids`, `audio_ids` or `video_id`."""
|
|
290
|
+
if url is not None:
|
|
291
|
+
return self._client._request("POST", "/generations/media", json_body={"url": url})
|
|
292
|
+
if data is None:
|
|
293
|
+
raise ValueError("Pass the file bytes, a file path or url=.")
|
|
294
|
+
if not isinstance(data, bytes):
|
|
295
|
+
path = Path(data)
|
|
296
|
+
filename = filename or path.name
|
|
297
|
+
data = path.read_bytes()
|
|
298
|
+
name = (filename or "upload").replace('"', "").replace("\r", "").replace("\n", "")
|
|
299
|
+
kind = content_type or mimetypes.guess_type(name)[0] or "application/octet-stream"
|
|
300
|
+
boundary = uuid.uuid4().hex
|
|
301
|
+
body = (
|
|
302
|
+
f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="{name}"\r\nContent-Type: {kind}\r\n\r\n'.encode()
|
|
303
|
+
+ data
|
|
304
|
+
+ f"\r\n--{boundary}--\r\n".encode()
|
|
305
|
+
)
|
|
306
|
+
return self._client._request(
|
|
307
|
+
"POST", "/generations/media", body=body, headers={"Content-Type": f"multipart/form-data; boundary={boundary}"}, retry=False
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
class AsyncCutPro:
|
|
312
|
+
"""Asyncio client with the same methods as `CutPro`. Each call runs in a worker thread and waiting never blocks the event loop."""
|
|
313
|
+
|
|
314
|
+
def __init__(self, api_key: Optional[str] = None, **options: Any) -> None:
|
|
315
|
+
self.sync = CutPro(api_key, **options)
|
|
316
|
+
self.models = _AsyncModels(self.sync)
|
|
317
|
+
self.generations = _AsyncGenerations(self.sync)
|
|
318
|
+
self.media = _AsyncMedia(self.sync)
|
|
319
|
+
|
|
320
|
+
def __repr__(self) -> str:
|
|
321
|
+
return f"Async{self.sync!r}"
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
class _AsyncModels:
|
|
325
|
+
def __init__(self, client: CutPro) -> None:
|
|
326
|
+
self._sync = client.models
|
|
327
|
+
|
|
328
|
+
async def list(self, **filters: Any) -> ModelList:
|
|
329
|
+
return await asyncio.to_thread(self._sync.list, **filters)
|
|
330
|
+
|
|
331
|
+
async def featured(self, **filters: Any) -> ModelList:
|
|
332
|
+
return await asyncio.to_thread(self._sync.featured, **filters)
|
|
333
|
+
|
|
334
|
+
async def prices(self) -> PriceList:
|
|
335
|
+
return await asyncio.to_thread(self._sync.prices)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
class _AsyncGenerations:
|
|
339
|
+
def __init__(self, client: CutPro) -> None:
|
|
340
|
+
self._sync = client.generations
|
|
341
|
+
|
|
342
|
+
async def quote(self, **request: Unpack[GenerationRequest]) -> Quote:
|
|
343
|
+
return await asyncio.to_thread(lambda: self._sync.quote(**request))
|
|
344
|
+
|
|
345
|
+
async def create(self, *, idempotency_key: Optional[str] = None, wait: Optional[int] = None, **request: Unpack[CreateGenerationRequest]) -> Generation:
|
|
346
|
+
return await asyncio.to_thread(lambda: self._sync.create(idempotency_key=idempotency_key, wait=wait, **request))
|
|
347
|
+
|
|
348
|
+
async def get(self, id: str, *, wait: Optional[int] = None) -> Generation:
|
|
349
|
+
return await asyncio.to_thread(self._sync.get, id, wait=wait)
|
|
350
|
+
|
|
351
|
+
async def wait(self, id: str, *, interval: float = 1.0, timeout: float = DEFAULT_WAIT_TIMEOUT) -> Generation:
|
|
352
|
+
deadline = time.monotonic() + timeout
|
|
353
|
+
while True:
|
|
354
|
+
generation = await self.get(id, wait=min(MAX_SERVER_WAIT_SECONDS, max(0, int(deadline - time.monotonic()))))
|
|
355
|
+
if generation["status"] != "processing":
|
|
356
|
+
return generation
|
|
357
|
+
if time.monotonic() + interval >= deadline:
|
|
358
|
+
raise CutProError("WAIT_TIMEOUT", 0, {"id": id})
|
|
359
|
+
await asyncio.sleep(interval)
|
|
360
|
+
|
|
361
|
+
async def generate(self, *, idempotency_key: Optional[str] = None, interval: float = 1.0, timeout: float = DEFAULT_WAIT_TIMEOUT, **request: Unpack[CreateGenerationRequest]) -> Generation:
|
|
362
|
+
started = await self.create(idempotency_key=idempotency_key, wait=min(MAX_SERVER_WAIT_SECONDS, int(timeout)), **request)
|
|
363
|
+
generation = await self.wait(started["id"], interval=interval, timeout=timeout) if started["status"] == "processing" else started
|
|
364
|
+
if generation["status"] == "failed":
|
|
365
|
+
raise CutProError(generation["error_code"] or "GENERATION_FAILED", 0, {"id": generation["id"]})
|
|
366
|
+
return generation
|
|
367
|
+
|
|
368
|
+
async def download(self, id: str) -> DownloadLink:
|
|
369
|
+
return await asyncio.to_thread(self._sync.download, id)
|
|
370
|
+
|
|
371
|
+
async def download_to(self, id: str, path: Union[str, Path]) -> Path:
|
|
372
|
+
return await asyncio.to_thread(self._sync.download_to, id, path)
|
|
373
|
+
|
|
374
|
+
async def webhook_secret(self) -> str:
|
|
375
|
+
return await asyncio.to_thread(self._sync.webhook_secret)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
class _AsyncMedia:
|
|
379
|
+
def __init__(self, client: CutPro) -> None:
|
|
380
|
+
self._sync = client.media
|
|
381
|
+
|
|
382
|
+
async def upload(self, data: Union[bytes, str, Path, None] = None, **options: Any) -> Media:
|
|
383
|
+
return await asyncio.to_thread(lambda: self._sync.upload(data, **options))
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def verify_webhook(raw_body: Union[bytes, str], signature: Optional[str], secret: str, *, tolerance: int = WEBHOOK_TOLERANCE_SECONDS) -> bool:
|
|
387
|
+
"""True when `CutPro-Signature` signs this exact raw body with your secret and is under five minutes old. Pass the body before any JSON parsing."""
|
|
388
|
+
parts = dict(part.split("=", 1) for part in (signature or "").split(",") if "=" in part)
|
|
389
|
+
try:
|
|
390
|
+
timestamp = int(parts.get("t", ""))
|
|
391
|
+
except ValueError:
|
|
392
|
+
return False
|
|
393
|
+
if abs(time.time() - timestamp) > tolerance:
|
|
394
|
+
return False
|
|
395
|
+
body = raw_body.encode() if isinstance(raw_body, str) else raw_body
|
|
396
|
+
expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256).hexdigest()
|
|
397
|
+
return hmac.compare_digest(expected, parts.get("v1", ""))
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def proxy_request(
|
|
401
|
+
method: str,
|
|
402
|
+
path: str,
|
|
403
|
+
*,
|
|
404
|
+
body: Optional[bytes] = None,
|
|
405
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
406
|
+
query: str = "",
|
|
407
|
+
api_key: Optional[str] = None,
|
|
408
|
+
transport: Optional[Transport] = None,
|
|
409
|
+
) -> Tuple[int, bytes]:
|
|
410
|
+
"""
|
|
411
|
+
Forwards a browser app's call from your own route to Cut.Pro with the key added on the server, so the page never sees it.
|
|
412
|
+
`path` is what follows your route's prefix, such as `/generations/quote`. Check your own session before calling it: every forwarded call spends your credits.
|
|
413
|
+
Returns (status, JSON body) for FastAPI, Flask, Django or any framework.
|
|
414
|
+
"""
|
|
415
|
+
key = api_key or os.environ.get("CUTPRO_API_KEY")
|
|
416
|
+
if not key:
|
|
417
|
+
return 500, b'{"code":"API_KEY_MISSING"}'
|
|
418
|
+
if not any(allowed == method and pattern.match(path) for allowed, pattern in PROXIED):
|
|
419
|
+
return 404, b'{"code":"NOT_FOUND"}'
|
|
420
|
+
if body is not None and len(body) > MAX_PROXY_BODY_BYTES:
|
|
421
|
+
return 413, b'{"code":"PAYLOAD_TOO_LARGE"}'
|
|
422
|
+
target = f"{API_ORIGIN}{PRICES_PATH}" if path == "/prices" else f"{API_ORIGIN}{API_PATH}{path}"
|
|
423
|
+
if query:
|
|
424
|
+
target = f"{target}?{query.lstrip('?')}"
|
|
425
|
+
sent = {"Accept": "application/json", "X-Api-Key": key}
|
|
426
|
+
for name, value in (headers or {}).items():
|
|
427
|
+
if name.lower() in ("content-type", "idempotency-key"):
|
|
428
|
+
sent[name] = value
|
|
429
|
+
status, _, raw = (transport or urllib_transport)(method, target, sent, body if method != "GET" else None, DEFAULT_TIMEOUT)
|
|
430
|
+
return status, raw
|
cutpro/py.typed
ADDED
|
File without changes
|
cutpro/types.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Dict, List, Literal, Optional, TypedDict, Union
|
|
4
|
+
|
|
5
|
+
GenerationKind = Literal["image", "video", "speech", "music", "audio", "avatar", "model3d", "tool", "videoedit", "story"]
|
|
6
|
+
ModelKind = Literal["image", "video", "speech", "music", "audio", "avatar", "model3d", "tool", "videoedit", "story", "text", "embedding"]
|
|
7
|
+
ModelEndpoint = Literal["generations", "chat_completions", "embeddings"]
|
|
8
|
+
GenerationMode = Literal["text", "image", "frames", "references", "edit", "control"]
|
|
9
|
+
AspectRatio = Literal["21:9", "16:9", "3:2", "4:3", "5:4", "1:1", "4:5", "3:4", "2:3", "9:16"]
|
|
10
|
+
Preset = Literal["fast", "balanced", "best"]
|
|
11
|
+
GenerationStatus = Literal["processing", "ready", "failed"]
|
|
12
|
+
OptionValue = Union[str, int, float, bool, List[str]]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class GenerationRequest(TypedDict, total=False):
|
|
16
|
+
kind: Literal["image", "video", "speech"]
|
|
17
|
+
model: str
|
|
18
|
+
preset: Preset
|
|
19
|
+
mode: GenerationMode
|
|
20
|
+
resolution: str
|
|
21
|
+
prompt: str
|
|
22
|
+
options: Dict[str, OptionValue]
|
|
23
|
+
seconds: int
|
|
24
|
+
aspect_ratio: AspectRatio
|
|
25
|
+
audio: bool
|
|
26
|
+
voice: str
|
|
27
|
+
image_ids: List[str]
|
|
28
|
+
image_urls: List[str]
|
|
29
|
+
audio_ids: List[str]
|
|
30
|
+
audio_urls: List[str]
|
|
31
|
+
video_id: str
|
|
32
|
+
orientation: Literal["image", "video"]
|
|
33
|
+
seed: int
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CreateGenerationRequest(GenerationRequest, total=False):
|
|
37
|
+
credits: int
|
|
38
|
+
webhook_url: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Quote(TypedDict):
|
|
42
|
+
credits: float
|
|
43
|
+
model: str
|
|
44
|
+
kind: GenerationKind
|
|
45
|
+
mode: Optional[GenerationMode]
|
|
46
|
+
resolution: Optional[str]
|
|
47
|
+
seconds: Optional[float]
|
|
48
|
+
aspect_ratio: Optional[AspectRatio]
|
|
49
|
+
audio: Optional[bool]
|
|
50
|
+
warnings: List[str]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class GenerationOutput(TypedDict):
|
|
54
|
+
image_id: Optional[str]
|
|
55
|
+
video_id: Optional[str]
|
|
56
|
+
audio_id: Optional[str]
|
|
57
|
+
url: str
|
|
58
|
+
thumbnail_url: Optional[str]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class GenerationFile(TypedDict):
|
|
62
|
+
kind: Literal["image", "video", "audio", "file"]
|
|
63
|
+
url: str
|
|
64
|
+
media_type: Optional[str]
|
|
65
|
+
bytes: int
|
|
66
|
+
media_id: Optional[str]
|
|
67
|
+
file_name: Optional[str]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Generation(TypedDict):
|
|
71
|
+
id: str
|
|
72
|
+
kind: GenerationKind
|
|
73
|
+
status: GenerationStatus
|
|
74
|
+
model: str
|
|
75
|
+
model_name: str
|
|
76
|
+
mode: Optional[GenerationMode]
|
|
77
|
+
resolution: Optional[str]
|
|
78
|
+
seconds: Optional[float]
|
|
79
|
+
aspect_ratio: Optional[AspectRatio]
|
|
80
|
+
voice: Optional[str]
|
|
81
|
+
prompt: Optional[str]
|
|
82
|
+
audio: Optional[bool]
|
|
83
|
+
voice_name: Optional[str]
|
|
84
|
+
image_ids: List[str]
|
|
85
|
+
audio_ids: List[str]
|
|
86
|
+
video_id: Optional[str]
|
|
87
|
+
seed: Optional[int]
|
|
88
|
+
credits_charged: float
|
|
89
|
+
error_code: Optional[str]
|
|
90
|
+
edit_id: Optional[str]
|
|
91
|
+
output: Optional[GenerationOutput]
|
|
92
|
+
outputs: List[GenerationFile]
|
|
93
|
+
text: Optional[str]
|
|
94
|
+
options: Optional[Dict[str, OptionValue]]
|
|
95
|
+
expected_seconds: Optional[float]
|
|
96
|
+
feedback: Optional[Literal["up", "down"]]
|
|
97
|
+
feedback_reason: Optional[str]
|
|
98
|
+
refund_status: Optional[Literal["requested", "approved", "denied"]]
|
|
99
|
+
downloaded: bool
|
|
100
|
+
removed: bool
|
|
101
|
+
created_at: str
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class ModelPricing(TypedDict):
|
|
105
|
+
unit: str
|
|
106
|
+
credits: float
|
|
107
|
+
mode: Optional[GenerationMode]
|
|
108
|
+
resolution: Optional[str]
|
|
109
|
+
silent_credits: Optional[float]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class Voice(TypedDict):
|
|
113
|
+
id: str
|
|
114
|
+
name: str
|
|
115
|
+
style: Optional[str]
|
|
116
|
+
gender: Optional[Literal["female", "male"]]
|
|
117
|
+
age: Optional[Literal["young", "mature"]]
|
|
118
|
+
language: Optional[str]
|
|
119
|
+
preview_url: Optional[str]
|
|
120
|
+
portrait_url: Optional[str]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class Model(TypedDict):
|
|
124
|
+
id: str
|
|
125
|
+
name: str
|
|
126
|
+
maker: str
|
|
127
|
+
kind: ModelKind
|
|
128
|
+
endpoint: ModelEndpoint
|
|
129
|
+
featured: bool
|
|
130
|
+
pricing: List[ModelPricing]
|
|
131
|
+
priced_by_request: bool
|
|
132
|
+
modes: List[GenerationMode]
|
|
133
|
+
resolutions: List[str]
|
|
134
|
+
seconds: List[float]
|
|
135
|
+
aspect_ratios: List[AspectRatio]
|
|
136
|
+
audio: Optional[Literal["always", "optional"]]
|
|
137
|
+
max_images: int
|
|
138
|
+
max_audios: int
|
|
139
|
+
max_video_seconds: Optional[float]
|
|
140
|
+
voices: List[Voice]
|
|
141
|
+
typical_seconds: Optional[float]
|
|
142
|
+
success_rate: Optional[float]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class PresetModel(TypedDict):
|
|
146
|
+
id: Preset
|
|
147
|
+
model: str
|
|
148
|
+
resolution: str
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class Presets(TypedDict):
|
|
152
|
+
video: List[PresetModel]
|
|
153
|
+
image: List[PresetModel]
|
|
154
|
+
speech: List[PresetModel]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class ModelList(TypedDict):
|
|
158
|
+
models: List[Model]
|
|
159
|
+
presets: Presets
|
|
160
|
+
prompt_max: int
|
|
161
|
+
max_running: int
|
|
162
|
+
improve_credits: float
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class Money(TypedDict):
|
|
166
|
+
credits: float
|
|
167
|
+
usd: float
|
|
168
|
+
brl: float
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class PublicPrice(Money):
|
|
172
|
+
mode: str
|
|
173
|
+
resolution: str
|
|
174
|
+
silent: Optional[Money]
|
|
175
|
+
lowest: bool
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class PublicModel(TypedDict):
|
|
179
|
+
id: str
|
|
180
|
+
name: str
|
|
181
|
+
maker: str
|
|
182
|
+
kind: Literal["video", "image", "speech"]
|
|
183
|
+
unit: Literal["second", "image", "1k_characters"]
|
|
184
|
+
modes: List[str]
|
|
185
|
+
resolutions: List[str]
|
|
186
|
+
seconds: List[int]
|
|
187
|
+
aspect_ratios: List[str]
|
|
188
|
+
audio: Optional[str]
|
|
189
|
+
max_images: int
|
|
190
|
+
max_video_seconds: Optional[float]
|
|
191
|
+
prices: List[PublicPrice]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class CreditPrice(TypedDict):
|
|
195
|
+
usd: float
|
|
196
|
+
brl: float
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class PriceList(TypedDict):
|
|
200
|
+
updated_at: str
|
|
201
|
+
credit: CreditPrice
|
|
202
|
+
models: List[PublicModel]
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class Media(TypedDict):
|
|
206
|
+
id: str
|
|
207
|
+
kind: Literal["image", "video", "audio"]
|
|
208
|
+
seconds: Optional[float]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class DownloadLink(TypedDict):
|
|
212
|
+
url: str
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: cutpro
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Cut.Pro API client for Python: Seedance, Wan, Kling, Veo, Nano Banana, Seedream, GPT Image and Gemini TTS with one key, priced per second, image or 1,000 characters
|
|
5
|
+
Project-URL: Homepage, https://cut.pro/docs/en/generations
|
|
6
|
+
Project-URL: Source, https://github.com/getcutpro/sdk-python
|
|
7
|
+
Project-URL: Issues, https://github.com/getcutpro/sdk-python/issues
|
|
8
|
+
Project-URL: Prices, https://cut.pro/en/models
|
|
9
|
+
Author: Cut.Pro
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: ai,api,cutpro,gemini-tts,gpt-image,image-generation,image-to-video,kling,nano-banana,sdk,seedance,seedance-api,seedream,text-to-speech,text-to-video,veo,video-generation,wan
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Multimedia :: Video
|
|
23
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.9
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# Cut.Pro Python SDK: Seedance, Wan, Kling, Veo and Nano Banana API in Python
|
|
29
|
+
|
|
30
|
+
[](https://pypi.org/project/cutpro/) [](LICENSE)
|
|
31
|
+
|
|
32
|
+
One API key for video, image and voice models: Seedance 2.5, Wan 3, Kling O3, Veo 3.1, Gemini Omni Flash, Nano Banana Pro, Seedream 5, Qwen Image, GPT Image, Grok Imagine, MiniMax and Gemini TTS voices. Priced per second of video, per image or per 1,000 characters of voice, in reais and dollars: see the [live price list](https://cut.pro/en/models). Pay with Pix or card; companies get an invoice.
|
|
33
|
+
|
|
34
|
+
Python 3.9 to 3.13, standard library only (no dependencies), fully typed (`py.typed`), sync and asyncio. A pure wheel, so it installs the same way in any environment:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install cutpro # pip, virtualenv, Anaconda and Miniconda environments
|
|
38
|
+
uv add cutpro # uv
|
|
39
|
+
poetry add cutpro # Poetry
|
|
40
|
+
pdm add cutpro # PDM
|
|
41
|
+
pipenv install cutpro # Pipenv
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Quickstart
|
|
45
|
+
|
|
46
|
+
Create a key at [cut.pro/studio/me/api-keys](https://cut.pro/studio/me/api-keys) and set `CUTPRO_API_KEY`. Every account starts with free credits.
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from cutpro import CutPro
|
|
50
|
+
|
|
51
|
+
cutpro = CutPro() # reads CUTPRO_API_KEY
|
|
52
|
+
|
|
53
|
+
video = cutpro.generations.generate(
|
|
54
|
+
model="bytedance/seedance-2.5",
|
|
55
|
+
prompt="a golden retriever surfing a wave at sunset, cinematic",
|
|
56
|
+
seconds=5,
|
|
57
|
+
resolution="720p",
|
|
58
|
+
aspect_ratio="16:9",
|
|
59
|
+
)
|
|
60
|
+
print(video["output"]["url"], video["credits_charged"], "credits")
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`generate()` quotes the request, starts it with the quoted total and waits until it is ready. Each step is also its own call.
|
|
64
|
+
|
|
65
|
+
## Seedance API in Python, step by step
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
import uuid
|
|
69
|
+
|
|
70
|
+
quote = cutpro.generations.quote(model="bytedance/seedance-2.5", prompt="a dog surfing", seconds=5)
|
|
71
|
+
print(quote["credits"], "credits") # show the price and wait for a yes
|
|
72
|
+
|
|
73
|
+
started = cutpro.generations.create(
|
|
74
|
+
model="bytedance/seedance-2.5",
|
|
75
|
+
prompt="a dog surfing",
|
|
76
|
+
seconds=5,
|
|
77
|
+
credits=quote["credits"],
|
|
78
|
+
idempotency_key=str(uuid.uuid4()),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
done = cutpro.generations.wait(started["id"], timeout=600)
|
|
82
|
+
if done["status"] == "ready":
|
|
83
|
+
print(done["output"]["url"])
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
- `create()` without `credits` quotes first and agrees to that total. With `credits`, the price is checked on the server: if it changed, nothing is charged and you get `PRICE_CHANGED` with the new total in `extra["credits"]`.
|
|
87
|
+
- `idempotency_key`: repeating the same request with the same key within 24 hours never charges twice.
|
|
88
|
+
- `webhook_url="https://..."` gets a signed POST when the generation ends, instead of waiting.
|
|
89
|
+
|
|
90
|
+
## Wan 3 image to video
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
clip = cutpro.generations.generate(
|
|
94
|
+
model="alibaba/wan-3",
|
|
95
|
+
mode="image",
|
|
96
|
+
image_urls=["https://your-cdn.com/product.jpg"],
|
|
97
|
+
prompt="the camera slowly orbits the product, soft studio light",
|
|
98
|
+
seconds=5,
|
|
99
|
+
resolution="720p",
|
|
100
|
+
)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Local file? `media = cutpro.media.upload(open("product.jpg", "rb").read(), filename="product.jpg", content_type="image/jpeg")` and send `image_ids=[media["id"]]`.
|
|
104
|
+
|
|
105
|
+
## Nano Banana Pro image with references
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
image = cutpro.generations.generate(
|
|
109
|
+
model="google/nano-banana-pro",
|
|
110
|
+
mode="references",
|
|
111
|
+
image_urls=["https://your-cdn.com/person.jpg", "https://your-cdn.com/jacket.jpg"],
|
|
112
|
+
prompt="the person from the first image wearing the jacket from the second, street photo",
|
|
113
|
+
aspect_ratio="4:5",
|
|
114
|
+
)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Gemini TTS voice
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
voices = cutpro.models.list(id="google/gemini-3.1-flash-tts")["models"][0]["voices"]
|
|
121
|
+
print([voice["id"] for voice in voices])
|
|
122
|
+
|
|
123
|
+
voice = cutpro.generations.generate(model="google/gemini-3.1-flash-tts", voice="Kore", prompt="Welcome to Cut.Pro. Your video is ready.")
|
|
124
|
+
print(voice["output"]["url"]) # MP3
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Models and prices
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
catalog = cutpro.models.list(kind="video", endpoint="generations")
|
|
131
|
+
featured = cutpro.models.featured(kind="image")
|
|
132
|
+
|
|
133
|
+
# Public price list, no key needed: credits, dollars and reais per unit.
|
|
134
|
+
prices = CutPro().models.prices()
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
| Kind | Model ids |
|
|
138
|
+
|---|---|
|
|
139
|
+
| Video | [`bytedance/seedance-2.5`](https://cut.pro/en/models/seedance-2-5), [`bytedance/seedance-2`](https://cut.pro/en/models/seedance-2), [`alibaba/wan-3`](https://cut.pro/en/models/wan-3), [`kling/kling-o3-pro`](https://cut.pro/en/models/kling-o3-pro), [`kling/kling-3-pro`](https://cut.pro/en/models/kling-3-pro), [`google/veo-3.1`](https://cut.pro/en/models/veo-3-1), [`google/gemini-omni-flash-1.1`](https://cut.pro/en/models/gemini-omni-flash-1-1), [`xai/grok-imagine-video-1.5`](https://cut.pro/en/models/grok-imagine-video-1-5), [`minimax/h3`](https://cut.pro/en/models/h3) |
|
|
140
|
+
| Image | [`google/nano-banana-pro`](https://cut.pro/en/models/nano-banana-pro), [`bytedance/seedream-5-pro`](https://cut.pro/en/models/seedream-5-pro), [`alibaba/qwen-image-3-pro`](https://cut.pro/en/models/qwen-image-3-pro), [`openai/gpt-image-2.5`](https://cut.pro/en/models/gpt-image-2-5), [`xai/grok-imagine-image-2`](https://cut.pro/en/models/grok-imagine-image-2) |
|
|
141
|
+
| Voice | [`google/gemini-3.1-flash-tts`](https://cut.pro/en/models/gemini-3-1-flash-tts), [`minimax/speech-2.8-hd`](https://cut.pro/en/models/speech-2-8-hd) |
|
|
142
|
+
|
|
143
|
+
The full catalog is in `models.list()` and at [cut.pro/en/models](https://cut.pro/en/models). Prices change: read them from the API, never hard-code them.
|
|
144
|
+
|
|
145
|
+
## Errors
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
from cutpro import CutProError
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
cutpro.generations.generate(model="bytedance/seedance-2.5", prompt="a dog surfing", seconds=3)
|
|
152
|
+
except CutProError as error:
|
|
153
|
+
if error.code == "INSUFFICIENT_CREDITS":
|
|
154
|
+
print("Top up at", error.extra["top_up_url"])
|
|
155
|
+
elif error.code == "SECONDS_INVALID":
|
|
156
|
+
print("Allowed seconds:", error.extra["allowed"])
|
|
157
|
+
else:
|
|
158
|
+
raise
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`code` is stable, `status` is the HTTP status and `extra` has what to act on. `status` is `0` for errors raised by the client: `WAIT_TIMEOUT`, `INSECURE_BASE_URL`, `INSECURE_DOWNLOAD_URL`, or a generation that ended `failed` (its `error_code` becomes `code`, and the credits come back on their own). Full list in the [error catalog](https://cut.pro/docs/en/errors).
|
|
162
|
+
|
|
163
|
+
## asyncio
|
|
164
|
+
|
|
165
|
+
`AsyncCutPro` has the same methods. Waiting never blocks the event loop, so FastAPI, aiohttp or a Jupyter notebook can run many generations at once.
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
import asyncio
|
|
169
|
+
from cutpro import AsyncCutPro
|
|
170
|
+
|
|
171
|
+
async def main() -> None:
|
|
172
|
+
cutpro = AsyncCutPro()
|
|
173
|
+
videos = await asyncio.gather(*(cutpro.generations.generate(model="bytedance/seedance-2-fast", prompt=p, seconds=5) for p in ["a dog surfing", "a cat skating"]))
|
|
174
|
+
print([video["output"]["url"] for video in videos])
|
|
175
|
+
|
|
176
|
+
asyncio.run(main())
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Download to a file
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
cutpro.generations.download_to(video["id"], "dog.mp4") # streams to disk, never holds the whole file in memory
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## Webhooks
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
from cutpro import verify_webhook
|
|
189
|
+
|
|
190
|
+
secret = cutpro.generations.webhook_secret() # store it with your other secrets
|
|
191
|
+
|
|
192
|
+
# In your webhook route, with the raw body before any JSON parsing:
|
|
193
|
+
if not verify_webhook(raw_body, headers.get("CutPro-Signature"), secret):
|
|
194
|
+
return 401
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
It checks the HMAC of the exact body, refuses signatures older than five minutes and compares in constant time.
|
|
198
|
+
|
|
199
|
+
## A browser app on your Python server
|
|
200
|
+
|
|
201
|
+
The key spends credits, so it never goes to a page. `proxy_request` forwards only what a generation needs (models, prices, quote, create, get) with the key added on your server. With FastAPI:
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
from fastapi import FastAPI, Request, Response
|
|
205
|
+
from cutpro import proxy_request
|
|
206
|
+
|
|
207
|
+
app = FastAPI()
|
|
208
|
+
|
|
209
|
+
@app.api_route("/api/cutpro/{path:path}", methods=["GET", "POST"])
|
|
210
|
+
async def cutpro_proxy(path: str, request: Request) -> Response:
|
|
211
|
+
if not is_signed_in(request): # whoever reaches this route spends your credits
|
|
212
|
+
return Response(status_code=401)
|
|
213
|
+
status, body = proxy_request(request.method, f"/{path}", body=await request.body(), headers=dict(request.headers), query=request.url.query)
|
|
214
|
+
return Response(body, status_code=status, media_type="application/json")
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
The page uses the JavaScript SDK with `new CutPro({ baseURL: "/api/cutpro" })`.
|
|
218
|
+
|
|
219
|
+
## Options
|
|
220
|
+
|
|
221
|
+
```python
|
|
222
|
+
CutPro(
|
|
223
|
+
api_key="...", # defaults to CUTPRO_API_KEY
|
|
224
|
+
workspace_id="...", # only for a key that reaches more than one workspace
|
|
225
|
+
base_url="https://api.cut.pro/api/v1",
|
|
226
|
+
timeout=90.0, # seconds per HTTP request
|
|
227
|
+
max_retries=2,
|
|
228
|
+
transport=my_transport, # (method, url, headers, body, timeout) -> (status, headers, body): plug in httpx or requests, or stub it in tests
|
|
229
|
+
)
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
## Security and reliability
|
|
233
|
+
|
|
234
|
+
- The key only travels in the `X-Api-Key` header, never in a URL, and redirects are refused so it never follows one to another host. `repr(client)` never shows it.
|
|
235
|
+
- `base_url` must be `https`, except on localhost.
|
|
236
|
+
- `generations.create` sends an `Idempotency-Key` (a fresh UUID unless you pass one), so a retry or a repeated call never charges twice.
|
|
237
|
+
- Network errors, `429` and `5xx` are retried twice with backoff, honoring `Retry-After`; uploads are never retried.
|
|
238
|
+
- Waiting uses the server's long poll instead of hammering it.
|
|
239
|
+
|
|
240
|
+
## Links
|
|
241
|
+
|
|
242
|
+
- [Generation guide](https://cut.pro/docs/en/generations) and [API reference](https://cut.pro/docs/reference)
|
|
243
|
+
- [Node SDK](https://github.com/getcutpro/sdk-js), [Vercel AI SDK provider](https://github.com/getcutpro/ai-sdk-provider), [n8n node](https://github.com/getcutpro/n8n-nodes-cutpro), [examples](https://github.com/getcutpro/examples)
|
|
244
|
+
|
|
245
|
+
## Em português
|
|
246
|
+
|
|
247
|
+
Instala igual com pip, uv, Poetry, PDM, Pipenv e em ambientes Anaconda ou Miniconda (`pip install cutpro`). Tem cliente síncrono e assíncrono (`AsyncCutPro`), verificação de webhook e proxy para usar no navegador sem expor a chave.
|
|
248
|
+
|
|
249
|
+
SDK oficial da Cut.Pro para Python: uma chave de API para Seedance 2.5, Wan 3, Kling O3, Veo 3.1, Nano Banana Pro, Seedream 5, GPT Image, Grok Imagine, MiniMax e vozes do Gemini TTS. Preço por segundo de vídeo, por imagem ou por 1.000 caracteres de voz, em reais: veja a [tabela de preços ao vivo](https://cut.pro/pt-BR/models). Pague com Pix ou cartão, com nota fiscal para empresas. Toda conta começa com créditos grátis.
|
|
250
|
+
|
|
251
|
+
```python
|
|
252
|
+
from cutpro import CutPro
|
|
253
|
+
|
|
254
|
+
cutpro = CutPro() # lê CUTPRO_API_KEY
|
|
255
|
+
voz = cutpro.generations.generate(model="google/gemini-3.1-flash-tts", voice="Kore", prompt="Olá! Seu vídeo está pronto.")
|
|
256
|
+
print(voz["output"]["url"])
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Crie a chave em [cut.pro/studio/me/api-keys](https://cut.pro/studio/me/api-keys). Guia em português: [cut.pro/docs/generations](https://cut.pro/docs/generations). Páginas dos modelos: [Seedance 2.5](https://cut.pro/pt-BR/models/seedance-2-5), [Wan 3](https://cut.pro/pt-BR/models/wan-3), [Nano Banana Pro](https://cut.pro/pt-BR/models/nano-banana-pro), [Gemini 3.1 Flash TTS](https://cut.pro/pt-BR/models/gemini-3-1-flash-tts).
|
|
260
|
+
|
|
261
|
+
## License
|
|
262
|
+
|
|
263
|
+
MIT
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
cutpro/__init__.py,sha256=omgfoWGG85ZKipYLHqRu1L_UHoSozYLZrGnsrxiX8wk,860
|
|
2
|
+
cutpro/_client.py,sha256=oe-_xxDhXhMDcqXP2zuJyzoMjG5lK7LuTA9J1vcvdWs,19307
|
|
3
|
+
cutpro/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
cutpro/types.py,sha256=raxX-mUBz6GGdKROwC5vEe8i6at6_mVy6od5_iEEmTU,5156
|
|
5
|
+
cutpro-0.1.0.dist-info/METADATA,sha256=GioTrhejP7d4gkG5OBjOkAftJqQcRnLpeMotGv32BzI,12205
|
|
6
|
+
cutpro-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
7
|
+
cutpro-0.1.0.dist-info/licenses/LICENSE,sha256=uYa0hjp2TQGA9FHrifYlkOIzz6hHpNZnwzc1HXYKg2U,1064
|
|
8
|
+
cutpro-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Cut.Pro
|
|
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.
|