heroshots 0.2.2__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.
- heroshots/__init__.py +48 -0
- heroshots/_polling.py +66 -0
- heroshots/client.py +205 -0
- heroshots/errors.py +153 -0
- heroshots/models.py +34 -0
- heroshots/resources/__init__.py +46 -0
- heroshots/resources/avatars.py +25 -0
- heroshots/resources/images.py +74 -0
- heroshots/resources/longvideo.py +61 -0
- heroshots/resources/stilllife.py +71 -0
- heroshots/resources/storyboard.py +158 -0
- heroshots/resources/uploads.py +30 -0
- heroshots/resources/videos.py +42 -0
- heroshots-0.2.2.dist-info/METADATA +104 -0
- heroshots-0.2.2.dist-info/RECORD +18 -0
- heroshots-0.2.2.dist-info/WHEEL +5 -0
- heroshots-0.2.2.dist-info/licenses/LICENSE +21 -0
- heroshots-0.2.2.dist-info/top_level.txt +1 -0
heroshots/__init__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""HeroShots SDK - high-level Python client for the StudioHero platform (JWT auth)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .client import HeroShotsClient
|
|
6
|
+
from .errors import (
|
|
7
|
+
ApiError,
|
|
8
|
+
AuthError,
|
|
9
|
+
ConflictError,
|
|
10
|
+
ForbiddenError,
|
|
11
|
+
HeroShotsError,
|
|
12
|
+
InternalError,
|
|
13
|
+
NotFoundError,
|
|
14
|
+
PayloadTooLargeError,
|
|
15
|
+
ProviderError,
|
|
16
|
+
QuotaExceededError,
|
|
17
|
+
RateLimitError,
|
|
18
|
+
ServiceUnavailableError,
|
|
19
|
+
TokenExpiredError,
|
|
20
|
+
ValidationError,
|
|
21
|
+
)
|
|
22
|
+
from ._polling import TimeoutError as PollTimeoutError
|
|
23
|
+
|
|
24
|
+
__version__ = "0.2.2"
|
|
25
|
+
|
|
26
|
+
# Friendly alias.
|
|
27
|
+
Client = HeroShotsClient
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"Client",
|
|
31
|
+
"HeroShotsClient",
|
|
32
|
+
"HeroShotsError",
|
|
33
|
+
"ApiError",
|
|
34
|
+
"AuthError",
|
|
35
|
+
"TokenExpiredError",
|
|
36
|
+
"ValidationError",
|
|
37
|
+
"NotFoundError",
|
|
38
|
+
"ForbiddenError",
|
|
39
|
+
"ConflictError",
|
|
40
|
+
"QuotaExceededError",
|
|
41
|
+
"PayloadTooLargeError",
|
|
42
|
+
"ProviderError",
|
|
43
|
+
"ServiceUnavailableError",
|
|
44
|
+
"RateLimitError",
|
|
45
|
+
"InternalError",
|
|
46
|
+
"PollTimeoutError",
|
|
47
|
+
"__version__",
|
|
48
|
+
]
|
heroshots/_polling.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Generic async-job polling helper.
|
|
2
|
+
|
|
3
|
+
The backend has three async surfaces with **inconsistent** terminal-state names:
|
|
4
|
+
|
|
5
|
+
* storyboard jobs and long-video jobs: success = ``done``, fail = ``failed``
|
|
6
|
+
* single video / image status: success = ``succeeded``, fail = ``failed``
|
|
7
|
+
|
|
8
|
+
Each resource passes the right state sets to :func:`wait`.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import time
|
|
14
|
+
from typing import Any, Callable, Iterable, Optional
|
|
15
|
+
|
|
16
|
+
from .errors import ProviderError, HeroShotsError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TimeoutError(HeroShotsError):
|
|
20
|
+
"""Polling exceeded the allotted timeout."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Default terminal states, lower-cased for comparison.
|
|
24
|
+
STORYBOARD_OK = ("done", "succeeded", "completed")
|
|
25
|
+
STORYBOARD_FAIL = ("failed", "error", "cancelled", "canceled")
|
|
26
|
+
MEDIA_OK = ("succeeded", "done", "completed")
|
|
27
|
+
MEDIA_FAIL = ("failed", "error", "cancelled", "canceled")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def wait(
|
|
31
|
+
get_fn: Callable[[], dict[str, Any]],
|
|
32
|
+
*,
|
|
33
|
+
terminal_ok: Iterable[str] = STORYBOARD_OK,
|
|
34
|
+
terminal_fail: Iterable[str] = STORYBOARD_FAIL,
|
|
35
|
+
status_key: str = "status",
|
|
36
|
+
interval: float = 3.0,
|
|
37
|
+
timeout: float = 600.0,
|
|
38
|
+
on_progress: Optional[Callable[[dict[str, Any]], None]] = None,
|
|
39
|
+
) -> dict[str, Any]:
|
|
40
|
+
"""Poll ``get_fn`` until the job reaches a terminal state.
|
|
41
|
+
|
|
42
|
+
Returns the final status payload on success. Raises :class:`ProviderError`
|
|
43
|
+
on a failed job and :class:`TimeoutError` if the deadline passes.
|
|
44
|
+
"""
|
|
45
|
+
ok = {s.lower() for s in terminal_ok}
|
|
46
|
+
fail = {s.lower() for s in terminal_fail}
|
|
47
|
+
deadline = time.monotonic() + timeout
|
|
48
|
+
while True:
|
|
49
|
+
payload = get_fn()
|
|
50
|
+
if on_progress is not None:
|
|
51
|
+
on_progress(payload)
|
|
52
|
+
status = str(payload.get(status_key) or "").lower()
|
|
53
|
+
if status in ok:
|
|
54
|
+
return payload
|
|
55
|
+
if status in fail:
|
|
56
|
+
err = payload.get("error") or {}
|
|
57
|
+
if isinstance(err, dict):
|
|
58
|
+
raise ProviderError(
|
|
59
|
+
err.get("message") or f"job {status}",
|
|
60
|
+
code=err.get("code") or "provider_error",
|
|
61
|
+
details=err if err else None,
|
|
62
|
+
)
|
|
63
|
+
raise ProviderError(str(err) or f"job {status}", code="provider_error")
|
|
64
|
+
if time.monotonic() >= deadline:
|
|
65
|
+
raise TimeoutError(f"job did not finish within {timeout:.0f}s (last status={status!r})")
|
|
66
|
+
time.sleep(interval)
|
heroshots/client.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""HeroShots API client.
|
|
2
|
+
|
|
3
|
+
Authenticates with a **portal JWT** (``Authorization: Bearer <token>``). The token
|
|
4
|
+
is obtained from ``POST /api/auth/login`` (HS256, 7-day TTL, no refresh endpoint -
|
|
5
|
+
re-login to renew). The client transparently handles the two response envelope
|
|
6
|
+
styles used by the backend:
|
|
7
|
+
|
|
8
|
+
* ``/api/v1/*`` -> ``{"success", "data", "meta"}`` -> unwrapped to ``data``
|
|
9
|
+
* everything else -> bare JSON, returned as-is
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
from typing import Any, BinaryIO, Optional
|
|
16
|
+
|
|
17
|
+
import requests
|
|
18
|
+
|
|
19
|
+
from .errors import error_from_response, TokenExpiredError
|
|
20
|
+
from .models import wrap
|
|
21
|
+
from .resources.avatars import Avatars
|
|
22
|
+
from .resources.images import Images
|
|
23
|
+
from .resources.longvideo import LongVideo
|
|
24
|
+
from .resources.stilllife import StillLife
|
|
25
|
+
from .resources.storyboard import Storyboard
|
|
26
|
+
from .resources.uploads import Uploads
|
|
27
|
+
from .resources.videos import Videos
|
|
28
|
+
|
|
29
|
+
DEFAULT_TIMEOUT = 120.0
|
|
30
|
+
DEFAULT_BASE_URL = "https://app.heroshots.ai"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class HeroShotsClient:
|
|
34
|
+
"""Entry point for the HeroShots API.
|
|
35
|
+
|
|
36
|
+
Example::
|
|
37
|
+
|
|
38
|
+
client = HeroShotsClient.from_login("dev@partner.com", "...")
|
|
39
|
+
client.storyboard.brief_suggestions(product_image_url="https://.../p.jpg")
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
base_url: Optional[str] = None,
|
|
45
|
+
token: Optional[str] = None,
|
|
46
|
+
*,
|
|
47
|
+
email: Optional[str] = None,
|
|
48
|
+
password: Optional[str] = None,
|
|
49
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
50
|
+
session: Optional[requests.Session] = None,
|
|
51
|
+
auto_relogin: bool = True,
|
|
52
|
+
) -> None:
|
|
53
|
+
base_url = base_url or os.environ.get("HEROSHOTS_BASE_URL") or DEFAULT_BASE_URL
|
|
54
|
+
self.base_url = base_url.rstrip("/")
|
|
55
|
+
self.token = token
|
|
56
|
+
self._email = email
|
|
57
|
+
self._password = password
|
|
58
|
+
self._auto_relogin = auto_relogin
|
|
59
|
+
self.timeout = timeout
|
|
60
|
+
self.session = session or requests.Session()
|
|
61
|
+
|
|
62
|
+
# Resource namespaces.
|
|
63
|
+
self.avatars = Avatars(self)
|
|
64
|
+
self.images = Images(self)
|
|
65
|
+
self.longvideo = LongVideo(self)
|
|
66
|
+
self.stilllife = StillLife(self)
|
|
67
|
+
self.storyboard = Storyboard(self)
|
|
68
|
+
self.uploads = Uploads(self)
|
|
69
|
+
self.videos = Videos(self)
|
|
70
|
+
|
|
71
|
+
# -- construction helpers -------------------------------------------------
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def from_login(
|
|
75
|
+
cls,
|
|
76
|
+
email: str,
|
|
77
|
+
password: str,
|
|
78
|
+
*,
|
|
79
|
+
base_url: Optional[str] = None,
|
|
80
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
81
|
+
auto_relogin: bool = True,
|
|
82
|
+
) -> "HeroShotsClient":
|
|
83
|
+
"""Create a client and log in with email/password in one step.
|
|
84
|
+
|
|
85
|
+
``base_url`` defaults to https://app.heroshots.ai.
|
|
86
|
+
"""
|
|
87
|
+
client = cls(
|
|
88
|
+
base_url,
|
|
89
|
+
email=email,
|
|
90
|
+
password=password,
|
|
91
|
+
timeout=timeout,
|
|
92
|
+
auto_relogin=auto_relogin,
|
|
93
|
+
)
|
|
94
|
+
client.login(email, password)
|
|
95
|
+
return client
|
|
96
|
+
|
|
97
|
+
# -- auth -----------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
def login(self, email: str, password: str) -> dict[str, Any]:
|
|
100
|
+
"""POST /api/auth/login -> stores and returns the JWT + profile."""
|
|
101
|
+
data = self.request(
|
|
102
|
+
"POST",
|
|
103
|
+
"/api/auth/login",
|
|
104
|
+
json={"email": email, "password": password},
|
|
105
|
+
auth=False,
|
|
106
|
+
)
|
|
107
|
+
self.token = data.get("token")
|
|
108
|
+
self._email, self._password = email, password
|
|
109
|
+
return data
|
|
110
|
+
|
|
111
|
+
def me(self) -> dict[str, Any]:
|
|
112
|
+
"""GET /api/auth/me -> current authenticated profile."""
|
|
113
|
+
return self.request("GET", "/api/auth/me")
|
|
114
|
+
|
|
115
|
+
def balance(self) -> dict[str, Any]:
|
|
116
|
+
"""GET /api/auth/stats -> account balance.
|
|
117
|
+
|
|
118
|
+
Includes ``hs_credits``, ``total_credits_used`` and ``plan.wallet_balance``.
|
|
119
|
+
"""
|
|
120
|
+
return self.request("GET", "/api/auth/stats")
|
|
121
|
+
|
|
122
|
+
# -- transport ------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
def request(
|
|
125
|
+
self,
|
|
126
|
+
method: str,
|
|
127
|
+
path: str,
|
|
128
|
+
*,
|
|
129
|
+
json: Optional[dict[str, Any]] = None,
|
|
130
|
+
params: Optional[dict[str, Any]] = None,
|
|
131
|
+
files: Optional[dict[str, Any]] = None,
|
|
132
|
+
data: Optional[dict[str, Any]] = None,
|
|
133
|
+
auth: bool = True,
|
|
134
|
+
unwrap: Optional[bool] = None,
|
|
135
|
+
_retried: bool = False,
|
|
136
|
+
) -> Any:
|
|
137
|
+
"""Perform an HTTP request and return decoded JSON (or raise ``ApiError``).
|
|
138
|
+
|
|
139
|
+
``unwrap`` controls envelope handling; when ``None`` it is inferred from the
|
|
140
|
+
path (``/api/v1/*`` responses are wrapped in ``{"success","data","meta"}``).
|
|
141
|
+
"""
|
|
142
|
+
url = self.base_url + path
|
|
143
|
+
headers: dict[str, str] = {}
|
|
144
|
+
if auth and self.token:
|
|
145
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
|
146
|
+
|
|
147
|
+
resp = self.session.request(
|
|
148
|
+
method.upper(),
|
|
149
|
+
url,
|
|
150
|
+
headers=headers,
|
|
151
|
+
json=json,
|
|
152
|
+
params=params,
|
|
153
|
+
files=files,
|
|
154
|
+
data=data,
|
|
155
|
+
timeout=self.timeout,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
body: Any = None
|
|
159
|
+
if resp.content:
|
|
160
|
+
try:
|
|
161
|
+
body = resp.json()
|
|
162
|
+
except ValueError:
|
|
163
|
+
body = None
|
|
164
|
+
|
|
165
|
+
if not resp.ok:
|
|
166
|
+
err = error_from_response(resp.status_code, body)
|
|
167
|
+
# One automatic re-login attempt on an expired token.
|
|
168
|
+
if (
|
|
169
|
+
isinstance(err, TokenExpiredError)
|
|
170
|
+
and self._auto_relogin
|
|
171
|
+
and not _retried
|
|
172
|
+
and self._email
|
|
173
|
+
and self._password
|
|
174
|
+
):
|
|
175
|
+
self.login(self._email, self._password)
|
|
176
|
+
return self.request(
|
|
177
|
+
method,
|
|
178
|
+
path,
|
|
179
|
+
json=json,
|
|
180
|
+
params=params,
|
|
181
|
+
files=files,
|
|
182
|
+
data=data,
|
|
183
|
+
auth=auth,
|
|
184
|
+
unwrap=unwrap,
|
|
185
|
+
_retried=True,
|
|
186
|
+
)
|
|
187
|
+
raise err
|
|
188
|
+
|
|
189
|
+
if unwrap is None:
|
|
190
|
+
unwrap = path.startswith("/api/v1/")
|
|
191
|
+
if unwrap and isinstance(body, dict) and "data" in body:
|
|
192
|
+
data_part = body.get("data")
|
|
193
|
+
meta = body.get("meta")
|
|
194
|
+
if isinstance(data_part, dict) and meta is not None:
|
|
195
|
+
# Surface billing/credits metadata without losing the typed shape.
|
|
196
|
+
data_part.setdefault("_meta", meta)
|
|
197
|
+
return wrap(data_part)
|
|
198
|
+
return wrap(body)
|
|
199
|
+
|
|
200
|
+
@staticmethod
|
|
201
|
+
def _multipart_file(file: "str | BinaryIO", field: str = "file") -> dict[str, Any]:
|
|
202
|
+
"""Build a ``files=`` dict for ``requests`` from a path or open file object."""
|
|
203
|
+
if isinstance(file, str):
|
|
204
|
+
return {field: open(file, "rb")} # noqa: SIM115 - requests closes it
|
|
205
|
+
return {field: file}
|
heroshots/errors.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Exception hierarchy for the HeroShots SDK.
|
|
2
|
+
|
|
3
|
+
Every structured API error returns ``{"success": false, "error": {"code", "message",
|
|
4
|
+
"details"}}`` (see backend ``api_error_response.py``). :func:`error_from_response`
|
|
5
|
+
maps the ``code`` to a specific exception class so callers can ``except`` precisely.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class HeroShotsError(Exception):
|
|
14
|
+
"""Base class for every error raised by the SDK."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ApiError(HeroShotsError):
|
|
18
|
+
"""An error returned by the HeroShots API.
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
code: machine-readable error code (e.g. ``"validation_error"``).
|
|
22
|
+
message: human-readable message.
|
|
23
|
+
status: HTTP status code.
|
|
24
|
+
details: optional structured details dict.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
message: str,
|
|
30
|
+
*,
|
|
31
|
+
code: Optional[str] = None,
|
|
32
|
+
status: Optional[int] = None,
|
|
33
|
+
details: Optional[dict[str, Any]] = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
super().__init__(message)
|
|
36
|
+
self.code = code
|
|
37
|
+
self.message = message
|
|
38
|
+
self.status = status
|
|
39
|
+
self.details = details or {}
|
|
40
|
+
|
|
41
|
+
def __str__(self) -> str: # pragma: no cover - cosmetic
|
|
42
|
+
prefix = f"[{self.status} {self.code}] " if self.code else ""
|
|
43
|
+
return f"{prefix}{self.message}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AuthError(ApiError):
|
|
47
|
+
"""Missing/invalid bearer token or unknown user."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class TokenExpiredError(AuthError):
|
|
51
|
+
"""The portal JWT expired. There is no refresh endpoint - re-login to renew."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ValidationError(ApiError):
|
|
55
|
+
"""The request payload failed validation."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class NotFoundError(ApiError):
|
|
59
|
+
"""The requested resource does not exist (or a job expired)."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ForbiddenError(ApiError):
|
|
63
|
+
"""Authenticated but not allowed (plan/scope/tenant gating)."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ConflictError(ApiError):
|
|
67
|
+
"""Resource conflict (e.g. email already registered)."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class QuotaExceededError(ApiError):
|
|
71
|
+
"""Credit/quota limit reached."""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class PayloadTooLargeError(ApiError):
|
|
75
|
+
"""Uploaded payload exceeded the size limit."""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ProviderError(ApiError):
|
|
79
|
+
"""An upstream generation provider failed (also raised for failed jobs)."""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ServiceUnavailableError(ApiError):
|
|
83
|
+
"""A backend dependency is not configured or temporarily unavailable."""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class RateLimitError(ApiError):
|
|
87
|
+
"""HTTP 429 - too many requests. Back off and retry."""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class InternalError(ApiError):
|
|
91
|
+
"""Unexpected server error (HTTP 5xx)."""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# code -> exception class. Codes mirror backend api_error_response.py CODE_* values.
|
|
95
|
+
_CODE_MAP: dict[str, type[ApiError]] = {
|
|
96
|
+
"missing_api_key": AuthError,
|
|
97
|
+
"missing_authorization": AuthError,
|
|
98
|
+
"missing_bearer_token": AuthError,
|
|
99
|
+
"invalid_api_key": AuthError,
|
|
100
|
+
"invalid_token": AuthError,
|
|
101
|
+
"invalid_or_expired_token": AuthError,
|
|
102
|
+
"unauthorized": AuthError,
|
|
103
|
+
"user_not_found": AuthError,
|
|
104
|
+
"token_expired": TokenExpiredError,
|
|
105
|
+
"validation_error": ValidationError,
|
|
106
|
+
"not_found": NotFoundError,
|
|
107
|
+
"resource_gone": NotFoundError,
|
|
108
|
+
"forbidden": ForbiddenError,
|
|
109
|
+
"plan_not_allowed": ForbiddenError,
|
|
110
|
+
"insufficient_scope": ForbiddenError,
|
|
111
|
+
"conflict": ConflictError,
|
|
112
|
+
"quota_exceeded": QuotaExceededError,
|
|
113
|
+
"payload_too_large": PayloadTooLargeError,
|
|
114
|
+
"provider_error": ProviderError,
|
|
115
|
+
"service_unavailable": ServiceUnavailableError,
|
|
116
|
+
"pricing_not_configured": ServiceUnavailableError,
|
|
117
|
+
"stripe_not_configured": ServiceUnavailableError,
|
|
118
|
+
"internal_error": InternalError,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def error_from_response(status: int, body: Any) -> ApiError:
|
|
123
|
+
"""Build the most specific :class:`ApiError` for an HTTP error response."""
|
|
124
|
+
code = None
|
|
125
|
+
message = None
|
|
126
|
+
details = None
|
|
127
|
+
if isinstance(body, dict):
|
|
128
|
+
err = body.get("error")
|
|
129
|
+
if isinstance(err, dict):
|
|
130
|
+
code = err.get("code")
|
|
131
|
+
message = err.get("message")
|
|
132
|
+
details = err.get("details")
|
|
133
|
+
elif isinstance(err, str):
|
|
134
|
+
message = err
|
|
135
|
+
message = message or body.get("message")
|
|
136
|
+
if status == 429:
|
|
137
|
+
cls: type[ApiError] = RateLimitError
|
|
138
|
+
elif code and code in _CODE_MAP:
|
|
139
|
+
cls = _CODE_MAP[code]
|
|
140
|
+
elif status and status >= 500:
|
|
141
|
+
cls = InternalError
|
|
142
|
+
elif status == 404:
|
|
143
|
+
cls = NotFoundError
|
|
144
|
+
elif status in (401, 403):
|
|
145
|
+
cls = AuthError
|
|
146
|
+
else:
|
|
147
|
+
cls = ApiError
|
|
148
|
+
return cls(
|
|
149
|
+
message or f"HTTP {status}",
|
|
150
|
+
code=code,
|
|
151
|
+
status=status,
|
|
152
|
+
details=details if isinstance(details, dict) else None,
|
|
153
|
+
)
|
heroshots/models.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Lightweight response wrapper.
|
|
2
|
+
|
|
3
|
+
The API returns plain JSON. :class:`AttrDict` wraps dict responses so callers can
|
|
4
|
+
use either ``result["video_url"]`` or ``result.video_url`` and still treat the
|
|
5
|
+
object as an ordinary dict. ``/api/v1`` billing metadata is exposed under ``_meta``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AttrDict(dict):
|
|
14
|
+
"""A dict that also supports attribute access (recursively)."""
|
|
15
|
+
|
|
16
|
+
def __getattr__(self, name: str) -> Any:
|
|
17
|
+
try:
|
|
18
|
+
return self[name]
|
|
19
|
+
except KeyError as exc: # pragma: no cover - mirrors AttributeError contract
|
|
20
|
+
raise AttributeError(name) from exc
|
|
21
|
+
|
|
22
|
+
def __setattr__(self, name: str, value: Any) -> None:
|
|
23
|
+
self[name] = value
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def wrap(value: Any) -> Any:
|
|
27
|
+
"""Recursively wrap dicts (and dicts inside lists) as :class:`AttrDict`."""
|
|
28
|
+
if isinstance(value, AttrDict):
|
|
29
|
+
return value
|
|
30
|
+
if isinstance(value, dict):
|
|
31
|
+
return AttrDict({k: wrap(v) for k, v in value.items()})
|
|
32
|
+
if isinstance(value, list):
|
|
33
|
+
return [wrap(v) for v in value]
|
|
34
|
+
return value
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Resource namespaces for the HeroShots client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Optional
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
9
|
+
from ..client import HeroShotsClient
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Resource:
|
|
13
|
+
"""Base class holding a back-reference to the client."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, client: "HeroShotsClient") -> None:
|
|
16
|
+
self._client = client
|
|
17
|
+
|
|
18
|
+
# Convenience proxies so resource code stays terse.
|
|
19
|
+
def _get(self, path: str, **kw: Any) -> Any:
|
|
20
|
+
return self._client.request("GET", path, **kw)
|
|
21
|
+
|
|
22
|
+
def _post(self, path: str, **kw: Any) -> Any:
|
|
23
|
+
return self._client.request("POST", path, **kw)
|
|
24
|
+
|
|
25
|
+
def _patch(self, path: str, **kw: Any) -> Any:
|
|
26
|
+
return self._client.request("PATCH", path, **kw)
|
|
27
|
+
|
|
28
|
+
def _put(self, path: str, **kw: Any) -> Any:
|
|
29
|
+
return self._client.request("PUT", path, **kw)
|
|
30
|
+
|
|
31
|
+
def _delete(self, path: str, **kw: Any) -> Any:
|
|
32
|
+
return self._client.request("DELETE", path, **kw)
|
|
33
|
+
|
|
34
|
+
def _resolve_image(self, value: Optional[str], folder: str = "references") -> Optional[str]:
|
|
35
|
+
"""If ``value`` is a local file path, upload it and return the hosted URL.
|
|
36
|
+
|
|
37
|
+
Pass-through for ``None`` and existing http(s) URLs. Lets every generation
|
|
38
|
+
method accept local paths anywhere a ``*_image_url`` is expected.
|
|
39
|
+
"""
|
|
40
|
+
if not value:
|
|
41
|
+
return value
|
|
42
|
+
if value.startswith("http://") or value.startswith("https://"):
|
|
43
|
+
return value
|
|
44
|
+
if os.path.exists(value):
|
|
45
|
+
return self._client.uploads.image(value, folder=folder)["public_url"]
|
|
46
|
+
return value
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Avatars (JWT-or-API-key, /api/v1/avatar).
|
|
2
|
+
|
|
3
|
+
Read-only listing of your own + default avatars, plus on-demand generation.
|
|
4
|
+
(Editing/deleting avatars is intentionally not exposed.)
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from . import Resource
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Avatars(Resource):
|
|
15
|
+
def list(self, **params: Any) -> dict[str, Any]:
|
|
16
|
+
"""GET /api/v1/avatar/list -> your own and the default avatars."""
|
|
17
|
+
return self._get("/api/v1/avatar/list", params=params or None)
|
|
18
|
+
|
|
19
|
+
def get(self, avatar_id: int) -> dict[str, Any]:
|
|
20
|
+
"""GET /api/v1/avatar/<id>."""
|
|
21
|
+
return self._get(f"/api/v1/avatar/{avatar_id}")
|
|
22
|
+
|
|
23
|
+
def generate(self, **payload: Any) -> dict[str, Any]:
|
|
24
|
+
"""POST /api/v1/avatar/generate -> request a new avatar."""
|
|
25
|
+
return self._post("/api/v1/avatar/generate", json=payload)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Image generation (JWT-or-API-key on /api/v1, portal candidates flow)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Callable, Optional
|
|
6
|
+
|
|
7
|
+
from . import Resource
|
|
8
|
+
from .. import _polling
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Images(Resource):
|
|
12
|
+
def generate(
|
|
13
|
+
self,
|
|
14
|
+
product_image_url: str,
|
|
15
|
+
subject_image_url: Optional[str] = None,
|
|
16
|
+
**payload: Any,
|
|
17
|
+
) -> dict[str, Any]:
|
|
18
|
+
"""POST /api/v1/image/generate (async) -> ``{generation_id, status, ...}``.
|
|
19
|
+
|
|
20
|
+
Local file paths are auto-uploaded. Provide ``subject_image_url``/``avatar_id``
|
|
21
|
+
unless ``still_life=True``.
|
|
22
|
+
"""
|
|
23
|
+
body = {
|
|
24
|
+
"product_image_url": self._resolve_image(product_image_url, "products"),
|
|
25
|
+
**payload,
|
|
26
|
+
}
|
|
27
|
+
if subject_image_url is not None:
|
|
28
|
+
body["subject_image_url"] = self._resolve_image(subject_image_url, "subjects")
|
|
29
|
+
return self._post("/api/v1/image/generate", json=body)
|
|
30
|
+
|
|
31
|
+
def candidates(
|
|
32
|
+
self,
|
|
33
|
+
product_image_url: str,
|
|
34
|
+
subject_image_url: Optional[str] = None,
|
|
35
|
+
**payload: Any,
|
|
36
|
+
) -> dict[str, Any]:
|
|
37
|
+
"""POST /api/auth/image/generate/candidates -> persistent candidate batch."""
|
|
38
|
+
body = {
|
|
39
|
+
"product_image_url": self._resolve_image(product_image_url, "products"),
|
|
40
|
+
**payload,
|
|
41
|
+
}
|
|
42
|
+
if subject_image_url is not None:
|
|
43
|
+
body["subject_image_url"] = self._resolve_image(subject_image_url, "subjects")
|
|
44
|
+
return self._post("/api/auth/image/generate/candidates", json=body)
|
|
45
|
+
|
|
46
|
+
def select_candidate(self, token: str, **payload: Any) -> dict[str, Any]:
|
|
47
|
+
"""POST /api/auth/image/generate/candidates/<token>/select."""
|
|
48
|
+
return self._post(f"/api/auth/image/generate/candidates/{token}/select", json=payload)
|
|
49
|
+
|
|
50
|
+
def status(self, generation_id: str) -> dict[str, Any]:
|
|
51
|
+
"""GET /api/v1/image/status/<generation_id>."""
|
|
52
|
+
return self._get(f"/api/v1/image/status/{generation_id}")
|
|
53
|
+
|
|
54
|
+
def download(self, log_id: str) -> dict[str, Any]:
|
|
55
|
+
"""GET /api/v1/image/<log_id>/download."""
|
|
56
|
+
return self._get(f"/api/v1/image/{log_id}/download")
|
|
57
|
+
|
|
58
|
+
def wait(
|
|
59
|
+
self,
|
|
60
|
+
generation_id: str,
|
|
61
|
+
*,
|
|
62
|
+
interval: float = 3.0,
|
|
63
|
+
timeout: float = 600.0,
|
|
64
|
+
on_progress: Optional[Callable[[dict[str, Any]], None]] = None,
|
|
65
|
+
) -> dict[str, Any]:
|
|
66
|
+
"""Poll image status until ``succeeded``/``failed``."""
|
|
67
|
+
return _polling.wait(
|
|
68
|
+
lambda: self.status(generation_id),
|
|
69
|
+
terminal_ok=_polling.MEDIA_OK,
|
|
70
|
+
terminal_fail=_polling.MEDIA_FAIL,
|
|
71
|
+
interval=interval,
|
|
72
|
+
timeout=timeout,
|
|
73
|
+
on_progress=on_progress,
|
|
74
|
+
)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Long / multi-segment video pipeline (portal JWT, /api/auth/long-video)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Callable, Optional
|
|
6
|
+
|
|
7
|
+
from . import Resource
|
|
8
|
+
from .. import _polling
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class LongVideo(Resource):
|
|
12
|
+
def start(
|
|
13
|
+
self,
|
|
14
|
+
image_url: Optional[str] = None,
|
|
15
|
+
*,
|
|
16
|
+
custom_prompt: Optional[str] = None,
|
|
17
|
+
storyboard: Optional[dict] = None,
|
|
18
|
+
ratio: str = "16:9",
|
|
19
|
+
resolution: str = "1080p",
|
|
20
|
+
**payload: Any,
|
|
21
|
+
) -> dict[str, Any]:
|
|
22
|
+
"""POST /api/auth/long-video/start -> ``{job_id, status, segment_count, ...}``.
|
|
23
|
+
|
|
24
|
+
Provide ``image_url`` (local paths auto-upload) or ``image_generation_id`` /
|
|
25
|
+
``product_image_url`` + ``subject_image_url``. Pass a ``storyboard`` produced
|
|
26
|
+
by ``client.storyboard`` to drive a planned multi-segment render.
|
|
27
|
+
"""
|
|
28
|
+
body: dict[str, Any] = {"ratio": ratio, "resolution": resolution, **payload}
|
|
29
|
+
if image_url is not None:
|
|
30
|
+
body["image_url"] = self._resolve_image(image_url, "products")
|
|
31
|
+
if custom_prompt is not None:
|
|
32
|
+
body["custom_prompt"] = custom_prompt
|
|
33
|
+
if storyboard is not None:
|
|
34
|
+
body["storyboard"] = storyboard
|
|
35
|
+
return self._post("/api/auth/long-video/start", json=body)
|
|
36
|
+
|
|
37
|
+
def status(self, job_id: int) -> dict[str, Any]:
|
|
38
|
+
"""GET /api/auth/long-video/status/<job_id>."""
|
|
39
|
+
return self._get(f"/api/auth/long-video/status/{job_id}")
|
|
40
|
+
|
|
41
|
+
def view(self, token: str) -> dict[str, Any]:
|
|
42
|
+
"""GET /api/auth/long-video/view/<token>."""
|
|
43
|
+
return self._get(f"/api/auth/long-video/view/{token}")
|
|
44
|
+
|
|
45
|
+
def wait(
|
|
46
|
+
self,
|
|
47
|
+
job_id: int,
|
|
48
|
+
*,
|
|
49
|
+
interval: float = 5.0,
|
|
50
|
+
timeout: float = 1800.0,
|
|
51
|
+
on_progress: Optional[Callable[[dict[str, Any]], None]] = None,
|
|
52
|
+
) -> dict[str, Any]:
|
|
53
|
+
"""Poll until ``done``/``failed`` -> payload with ``final_video_url``."""
|
|
54
|
+
return _polling.wait(
|
|
55
|
+
lambda: self.status(job_id),
|
|
56
|
+
terminal_ok=_polling.STORYBOARD_OK,
|
|
57
|
+
terminal_fail=_polling.STORYBOARD_FAIL,
|
|
58
|
+
interval=interval,
|
|
59
|
+
timeout=timeout,
|
|
60
|
+
on_progress=on_progress,
|
|
61
|
+
)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Still-life product video (portal JWT, /api/auth/stilllife-video)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Callable, Optional
|
|
6
|
+
|
|
7
|
+
from . import Resource
|
|
8
|
+
from .. import _polling
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class StillLife(Resource):
|
|
12
|
+
def start(
|
|
13
|
+
self,
|
|
14
|
+
product_url: str,
|
|
15
|
+
background_url: str,
|
|
16
|
+
*,
|
|
17
|
+
custom_prompt: Optional[str] = None,
|
|
18
|
+
ratio: str = "16:9",
|
|
19
|
+
resolution: str = "1080p",
|
|
20
|
+
duration_seconds: int = 5,
|
|
21
|
+
**payload: Any,
|
|
22
|
+
) -> dict[str, Any]:
|
|
23
|
+
"""POST /api/auth/stilllife-video/start -> ``{task_id, status, ...}``.
|
|
24
|
+
|
|
25
|
+
``product_url`` and ``background_url`` are required (local paths auto-upload).
|
|
26
|
+
"""
|
|
27
|
+
body: dict[str, Any] = {
|
|
28
|
+
"product_url": self._resolve_image(product_url, "products"),
|
|
29
|
+
"background_url": self._resolve_image(background_url, "background"),
|
|
30
|
+
"ratio": ratio,
|
|
31
|
+
"resolution": resolution,
|
|
32
|
+
"duration_seconds": duration_seconds,
|
|
33
|
+
**payload,
|
|
34
|
+
}
|
|
35
|
+
if custom_prompt is not None:
|
|
36
|
+
body["custom_prompt"] = custom_prompt
|
|
37
|
+
return self._post("/api/auth/stilllife-video/start", json=body)
|
|
38
|
+
|
|
39
|
+
def status(self, task_id: str) -> dict[str, Any]:
|
|
40
|
+
"""GET /api/auth/stilllife-video/status/<task_id>."""
|
|
41
|
+
return self._get(f"/api/auth/stilllife-video/status/{task_id}")
|
|
42
|
+
|
|
43
|
+
def wait(
|
|
44
|
+
self,
|
|
45
|
+
task_id: str,
|
|
46
|
+
*,
|
|
47
|
+
interval: float = 5.0,
|
|
48
|
+
timeout: float = 900.0,
|
|
49
|
+
on_progress: Optional[Callable[[dict[str, Any]], None]] = None,
|
|
50
|
+
) -> dict[str, Any]:
|
|
51
|
+
"""Poll until terminal -> payload with the finished video URL."""
|
|
52
|
+
return _polling.wait(
|
|
53
|
+
lambda: self.status(task_id),
|
|
54
|
+
terminal_ok=_polling.MEDIA_OK,
|
|
55
|
+
terminal_fail=_polling.MEDIA_FAIL,
|
|
56
|
+
interval=interval,
|
|
57
|
+
timeout=timeout,
|
|
58
|
+
on_progress=on_progress,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def preview_background(self, task_id: str, **payload: Any) -> dict[str, Any]:
|
|
62
|
+
"""POST /api/auth/stilllife-video/<task_id>/preview-background."""
|
|
63
|
+
return self._post(f"/api/auth/stilllife-video/{task_id}/preview-background", json=payload)
|
|
64
|
+
|
|
65
|
+
def change_background(self, task_id: str, **payload: Any) -> dict[str, Any]:
|
|
66
|
+
"""POST /api/auth/stilllife-video/<task_id>/change-background."""
|
|
67
|
+
return self._post(f"/api/auth/stilllife-video/{task_id}/change-background", json=payload)
|
|
68
|
+
|
|
69
|
+
def change_background_status(self, task_id: str) -> dict[str, Any]:
|
|
70
|
+
"""GET /api/auth/stilllife-video/<task_id>/change-background/status."""
|
|
71
|
+
return self._get(f"/api/auth/stilllife-video/{task_id}/change-background/status")
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""StudioHero storyboard intelligence (portal JWT only).
|
|
2
|
+
|
|
3
|
+
This is the creative brain the web app uses: brief suggestions -> selectable
|
|
4
|
+
candidates -> full storyboard (async) -> deferred 4K reference frames (async) ->
|
|
5
|
+
optional revision. Feed the resulting storyboard into ``client.longvideo.start``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Callable, Optional
|
|
11
|
+
|
|
12
|
+
from . import Resource
|
|
13
|
+
from .. import _polling
|
|
14
|
+
|
|
15
|
+
# Valid durations accepted by the backend storyboard endpoints.
|
|
16
|
+
DURATIONS = (6, 8, 12, 15, 30, 45, 60)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Storyboard(Resource):
|
|
20
|
+
def brief_suggestions(
|
|
21
|
+
self,
|
|
22
|
+
product_image_url: Optional[str] = None,
|
|
23
|
+
subject_image_url: Optional[str] = None,
|
|
24
|
+
**payload: Any,
|
|
25
|
+
) -> list[str]:
|
|
26
|
+
"""POST /video/storyboard/brief-suggestions -> list of short brief strings."""
|
|
27
|
+
body = self._image_body(product_image_url, subject_image_url, payload)
|
|
28
|
+
resp = self._post("/video/storyboard/brief-suggestions", json=body)
|
|
29
|
+
return resp.get("suggestions", resp) if isinstance(resp, dict) else resp
|
|
30
|
+
|
|
31
|
+
def candidates(
|
|
32
|
+
self,
|
|
33
|
+
custom_prompt: str,
|
|
34
|
+
duration_seconds: int,
|
|
35
|
+
product_image_url: Optional[str] = None,
|
|
36
|
+
subject_image_url: Optional[str] = None,
|
|
37
|
+
*,
|
|
38
|
+
async_storyboard: bool = False,
|
|
39
|
+
**payload: Any,
|
|
40
|
+
) -> Any:
|
|
41
|
+
"""POST /video/storyboard/candidates -> selectable concept candidates.
|
|
42
|
+
|
|
43
|
+
Set ``async_storyboard=True`` to get a ``{job_id}`` instead (poll with
|
|
44
|
+
:meth:`wait`); otherwise returns the candidates inline.
|
|
45
|
+
"""
|
|
46
|
+
body = self._image_body(product_image_url, subject_image_url, payload)
|
|
47
|
+
body.update(custom_prompt=custom_prompt, duration_seconds=duration_seconds)
|
|
48
|
+
if async_storyboard:
|
|
49
|
+
body["async_storyboard"] = True
|
|
50
|
+
resp = self._post("/video/storyboard/candidates", json=body)
|
|
51
|
+
if isinstance(resp, dict) and "candidates" in resp:
|
|
52
|
+
return resp["candidates"]
|
|
53
|
+
return resp
|
|
54
|
+
|
|
55
|
+
def generate(
|
|
56
|
+
self,
|
|
57
|
+
custom_prompt: str,
|
|
58
|
+
duration_seconds: int,
|
|
59
|
+
selected_candidate: Optional[dict] = None,
|
|
60
|
+
product_image_url: Optional[str] = None,
|
|
61
|
+
subject_image_url: Optional[str] = None,
|
|
62
|
+
*,
|
|
63
|
+
async_storyboard: bool = True,
|
|
64
|
+
defer_reference_frames: bool = True,
|
|
65
|
+
**payload: Any,
|
|
66
|
+
) -> dict[str, Any]:
|
|
67
|
+
"""POST /video/storyboard -> storyboard (async by default -> ``{job_id}``)."""
|
|
68
|
+
body = self._image_body(product_image_url, subject_image_url, payload)
|
|
69
|
+
body.update(
|
|
70
|
+
custom_prompt=custom_prompt,
|
|
71
|
+
duration_seconds=duration_seconds,
|
|
72
|
+
async_storyboard=async_storyboard,
|
|
73
|
+
defer_reference_frames=defer_reference_frames,
|
|
74
|
+
)
|
|
75
|
+
if selected_candidate is not None:
|
|
76
|
+
body["selected_candidate"] = selected_candidate
|
|
77
|
+
return self._post("/video/storyboard", json=body)
|
|
78
|
+
|
|
79
|
+
def frames(
|
|
80
|
+
self,
|
|
81
|
+
storyboard: dict,
|
|
82
|
+
product_image_url: Optional[str] = None,
|
|
83
|
+
subject_image_url: Optional[str] = None,
|
|
84
|
+
*,
|
|
85
|
+
async_storyboard: bool = True,
|
|
86
|
+
**payload: Any,
|
|
87
|
+
) -> dict[str, Any]:
|
|
88
|
+
"""POST /video/storyboard/frames -> renders deferred 4K reference frames."""
|
|
89
|
+
body = self._image_body(product_image_url, subject_image_url, payload)
|
|
90
|
+
body.update(storyboard=storyboard, async_storyboard=async_storyboard)
|
|
91
|
+
return self._post("/video/storyboard/frames", json=body)
|
|
92
|
+
|
|
93
|
+
def revise(self, revision_request: str, custom_prompt: str, duration_seconds: int,
|
|
94
|
+
storyboard: dict, **payload: Any) -> dict[str, Any]:
|
|
95
|
+
"""POST /video/storyboard/revise -> revised storyboard."""
|
|
96
|
+
body = {
|
|
97
|
+
"revision_request": revision_request,
|
|
98
|
+
"custom_prompt": custom_prompt,
|
|
99
|
+
"duration_seconds": duration_seconds,
|
|
100
|
+
"storyboard": storyboard,
|
|
101
|
+
**payload,
|
|
102
|
+
}
|
|
103
|
+
return self._post("/video/storyboard/revise", json=body)
|
|
104
|
+
|
|
105
|
+
def status(self, job_id: str) -> dict[str, Any]:
|
|
106
|
+
"""GET /video/storyboard/status/<job_id>."""
|
|
107
|
+
return self._get(f"/video/storyboard/status/{job_id}")
|
|
108
|
+
|
|
109
|
+
def wait(
|
|
110
|
+
self,
|
|
111
|
+
job_id: str,
|
|
112
|
+
*,
|
|
113
|
+
interval: float = 3.0,
|
|
114
|
+
timeout: float = 300.0,
|
|
115
|
+
on_progress: Optional[Callable[[dict[str, Any]], None]] = None,
|
|
116
|
+
) -> dict[str, Any]:
|
|
117
|
+
"""Poll a storyboard/frames job until ``done``/``failed``.
|
|
118
|
+
|
|
119
|
+
``on_progress`` receives each status payload (storyboard frame jobs expose
|
|
120
|
+
``frames_partial`` for progressive rendering).
|
|
121
|
+
"""
|
|
122
|
+
return _polling.wait(
|
|
123
|
+
lambda: self.status(job_id),
|
|
124
|
+
terminal_ok=_polling.STORYBOARD_OK,
|
|
125
|
+
terminal_fail=_polling.STORYBOARD_FAIL,
|
|
126
|
+
interval=interval,
|
|
127
|
+
timeout=timeout,
|
|
128
|
+
on_progress=on_progress,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# -- creative helpers -----------------------------------------------------
|
|
132
|
+
def subject_measurements(self, **payload: Any) -> dict[str, Any]:
|
|
133
|
+
"""POST /subject/estimate-measurements."""
|
|
134
|
+
return self._post("/subject/estimate-measurements", json=payload)
|
|
135
|
+
|
|
136
|
+
def analyze_product(self, **payload: Any) -> dict[str, Any]:
|
|
137
|
+
"""POST /product/analyze."""
|
|
138
|
+
return self._post("/product/analyze", json=payload)
|
|
139
|
+
|
|
140
|
+
def creativity(self, **payload: Any) -> dict[str, Any]:
|
|
141
|
+
"""POST /creativity/generate."""
|
|
142
|
+
return self._post("/creativity/generate", json=payload)
|
|
143
|
+
|
|
144
|
+
# -- internal -------------------------------------------------------------
|
|
145
|
+
def _image_body(
|
|
146
|
+
self,
|
|
147
|
+
product_image_url: Optional[str],
|
|
148
|
+
subject_image_url: Optional[str],
|
|
149
|
+
payload: dict[str, Any],
|
|
150
|
+
) -> dict[str, Any]:
|
|
151
|
+
body = dict(payload)
|
|
152
|
+
if product_image_url is not None:
|
|
153
|
+
body["product_image_url"] = self._resolve_image(product_image_url, "products")
|
|
154
|
+
if subject_image_url is not None:
|
|
155
|
+
body["subject_image_url"] = self._resolve_image(subject_image_url, "subjects")
|
|
156
|
+
if "image_url" in body:
|
|
157
|
+
body["image_url"] = self._resolve_image(body["image_url"], "references")
|
|
158
|
+
return body
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""File uploads -> hosted public URLs (portal JWT)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, BinaryIO, Optional
|
|
6
|
+
|
|
7
|
+
from . import Resource
|
|
8
|
+
|
|
9
|
+
# Allowed upload folders (backend frontend_uploads.py).
|
|
10
|
+
FOLDERS = (
|
|
11
|
+
"subjects",
|
|
12
|
+
"products",
|
|
13
|
+
"avatars",
|
|
14
|
+
"avatar-consents",
|
|
15
|
+
"references",
|
|
16
|
+
"background",
|
|
17
|
+
"body_posture",
|
|
18
|
+
"reference-videos",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Uploads(Resource):
|
|
23
|
+
def image(self, file: "str | BinaryIO", folder: Optional[str] = None) -> dict[str, Any]:
|
|
24
|
+
"""POST /upload/image (multipart ``file``) -> ``{public_url, filename, folder}``.
|
|
25
|
+
|
|
26
|
+
``folder`` must be one of :data:`FOLDERS` (or omitted).
|
|
27
|
+
"""
|
|
28
|
+
files = self._client._multipart_file(file)
|
|
29
|
+
data = {"folder": folder} if folder else None
|
|
30
|
+
return self._post("/upload/image", files=files, data=data)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Short video generation (JWT-or-API-key on /api/v1; portal status poll)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Callable, Optional
|
|
6
|
+
|
|
7
|
+
from . import Resource
|
|
8
|
+
from .. import _polling
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Videos(Resource):
|
|
12
|
+
def generate(self, image_url: Optional[str] = None, **payload: Any) -> dict[str, Any]:
|
|
13
|
+
"""POST /api/v1/video/generate -> ``{task_id, status, ...}``.
|
|
14
|
+
|
|
15
|
+
Local file paths in ``image_url`` are auto-uploaded.
|
|
16
|
+
"""
|
|
17
|
+
body = dict(payload)
|
|
18
|
+
if image_url is not None:
|
|
19
|
+
body["image_url"] = self._resolve_image(image_url, "products")
|
|
20
|
+
return self._post("/api/v1/video/generate", json=body)
|
|
21
|
+
|
|
22
|
+
def status(self, task_id: str) -> dict[str, Any]:
|
|
23
|
+
"""GET /video/status/<task_id> (portal JWT) -> ``{task_id, status, video_url}``."""
|
|
24
|
+
return self._get(f"/video/status/{task_id}")
|
|
25
|
+
|
|
26
|
+
def wait(
|
|
27
|
+
self,
|
|
28
|
+
task_id: str,
|
|
29
|
+
*,
|
|
30
|
+
interval: float = 5.0,
|
|
31
|
+
timeout: float = 900.0,
|
|
32
|
+
on_progress: Optional[Callable[[dict[str, Any]], None]] = None,
|
|
33
|
+
) -> dict[str, Any]:
|
|
34
|
+
"""Poll video status until ``succeeded``/``failed`` -> payload with ``video_url``."""
|
|
35
|
+
return _polling.wait(
|
|
36
|
+
lambda: self.status(task_id),
|
|
37
|
+
terminal_ok=_polling.MEDIA_OK,
|
|
38
|
+
terminal_fail=_polling.MEDIA_FAIL,
|
|
39
|
+
interval=interval,
|
|
40
|
+
timeout=timeout,
|
|
41
|
+
on_progress=on_progress,
|
|
42
|
+
)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: heroshots
|
|
3
|
+
Version: 0.2.2
|
|
4
|
+
Summary: Official Python SDK for the HeroShots / StudioHero platform (JWT auth).
|
|
5
|
+
Author: HeroShots
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://heroshots.ai
|
|
8
|
+
Project-URL: Repository, https://github.com/JasonPriore/HeroShotsSDK-Python
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Dist: requests>=2.28
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
15
|
+
Requires-Dist: responses>=0.23; extra == "dev"
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# heroshots (Python SDK)
|
|
19
|
+
|
|
20
|
+
High-level Python client for the HeroShots / StudioHero platform, authenticated
|
|
21
|
+
with a **portal JWT**. Covers the full JWT-reachable API surface.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install heroshots # from a registry
|
|
27
|
+
pip install -e ".[dev]" # local dev (runs the test suite)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Requires Python 3.9+. Only runtime dependency: `requests`.
|
|
31
|
+
|
|
32
|
+
## Authenticate
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from heroshots import Client
|
|
36
|
+
|
|
37
|
+
# Email/password login (recommended): stores the JWT, auto re-logins on expiry.
|
|
38
|
+
# Base URL defaults to https://app.heroshots.ai.
|
|
39
|
+
client = Client.from_login("dev@partner.com", "secret")
|
|
40
|
+
|
|
41
|
+
# Or reuse an existing token.
|
|
42
|
+
client = Client(token="eyJ...")
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Usage
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
# Local file paths are auto-uploaded to hosted URLs.
|
|
49
|
+
briefs = client.storyboard.brief_suggestions(
|
|
50
|
+
product_image_url="product.jpg", subject_image_url="model.jpg",
|
|
51
|
+
duration_seconds=30, language_code="it",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
candidates = client.storyboard.candidates(
|
|
55
|
+
custom_prompt=briefs[0], duration_seconds=30,
|
|
56
|
+
product_image_url="product.jpg", subject_image_url="model.jpg",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
job = client.storyboard.generate(
|
|
60
|
+
custom_prompt=briefs[0], duration_seconds=30, selected_candidate=candidates[0],
|
|
61
|
+
product_image_url="product.jpg", subject_image_url="model.jpg",
|
|
62
|
+
)
|
|
63
|
+
sb = client.storyboard.wait(job["job_id"]) # polls until done/failed
|
|
64
|
+
|
|
65
|
+
vjob = client.longvideo.start(image_url="product.jpg", storyboard=sb["storyboard"])
|
|
66
|
+
final = client.longvideo.wait(vjob["job_id"])
|
|
67
|
+
print(final["final_video_url"]) # also: final.final_video_url
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Responses support both `result["key"]` and `result.key` access. `/api/v1`
|
|
71
|
+
billing metadata is under `result["_meta"]`.
|
|
72
|
+
|
|
73
|
+
## Resources
|
|
74
|
+
|
|
75
|
+
`uploads` · `images` · `videos` · `longvideo` · `stilllife` · `storyboard` ·
|
|
76
|
+
`avatars`
|
|
77
|
+
|
|
78
|
+
Plus on the client: `login()`, `me()`, `balance()`.
|
|
79
|
+
|
|
80
|
+
Every method maps 1:1 to an endpoint; pass extra backend fields as keyword
|
|
81
|
+
arguments (they are forwarded verbatim).
|
|
82
|
+
|
|
83
|
+
## Errors
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from heroshots import ValidationError, TokenExpiredError, RateLimitError
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
client.storyboard.candidates(custom_prompt="", duration_seconds=30,
|
|
90
|
+
product_image_url="p.jpg")
|
|
91
|
+
except ValidationError as e:
|
|
92
|
+
print(e.code, e.message, e.details)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Test
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
pytest # offline, mocked HTTP
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Billing
|
|
102
|
+
|
|
103
|
+
Generations cost credits. Check remaining credits with `client.balance()`
|
|
104
|
+
(`hs_credits` + `plan.wallet_balance`). See `examples/full_storyboard_flow.py`.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
heroshots/__init__.py,sha256=KIkIZOdOao0abY5T2aEdZCRjvwwvASlAPENfygDKLJg,1005
|
|
2
|
+
heroshots/_polling.py,sha256=RFBo3aaEBfzWgnQjxaTUTs2GICMhCYA5dgM0r_VyRdU,2370
|
|
3
|
+
heroshots/client.py,sha256=C8Sn7t27jxQ3bexFe5p4U_rWGrSRR7XKjs_jspbzF6g,6767
|
|
4
|
+
heroshots/errors.py,sha256=HYvGyUGaRn5IXmYmwDmqLIB00lnahShV2boeLKkNj84,4524
|
|
5
|
+
heroshots/models.py,sha256=1MKIIZweqwa4GzlBjU8iD13-ibxmHaw5IJS_6IGeWD8,1098
|
|
6
|
+
heroshots/resources/__init__.py,sha256=GQ-_87vMPoyONcJMRYI34rouFJ5Ks0ybzlQrXHcOSV8,1621
|
|
7
|
+
heroshots/resources/avatars.py,sha256=H002S1IkRZHqtUoABlRq8T_zBGqDakwkPThNqaHI6nw,836
|
|
8
|
+
heroshots/resources/images.py,sha256=DuPLltDzb-blfdJV0bX0StnAz8JHqZM66MTz2ZsdBnQ,2702
|
|
9
|
+
heroshots/resources/longvideo.py,sha256=s-IU6M53_OH0pguwAjW2XizNz3JFvt3lfZbiiAh8t8g,2216
|
|
10
|
+
heroshots/resources/stilllife.py,sha256=H7TquATf4TfzYXk8vfASA3Ej5ye6H5eKFrraQwRLJ1I,2700
|
|
11
|
+
heroshots/resources/storyboard.py,sha256=f6PT234u2RKUH3vQLfQHiwnjxpw98LNFEXS0QAkVetU,6209
|
|
12
|
+
heroshots/resources/uploads.py,sha256=rcSqx4dwc0g1tzs7v2W8s14jfkcOl7x-c9dYxCZhJ9w,839
|
|
13
|
+
heroshots/resources/videos.py,sha256=S-aJ_58BXcFMCgteWa358MP1G-UvYDJe6eG8cVmU4zo,1453
|
|
14
|
+
heroshots-0.2.2.dist-info/licenses/LICENSE,sha256=ZW7TmjdKjIlHN4RDzJIerL-ik9dg0MvcUQ43keMA024,1069
|
|
15
|
+
heroshots-0.2.2.dist-info/METADATA,sha256=VGhNS3tVF1HuZeXosuydsUqfuyKEoZbmiY0qEQr9VMs,3041
|
|
16
|
+
heroshots-0.2.2.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
17
|
+
heroshots-0.2.2.dist-info/top_level.txt,sha256=bbmXrf-MXXJ0q7jUR8C0rWtUmC-KIBECToHCGSqNY3o,10
|
|
18
|
+
heroshots-0.2.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jason Priore
|
|
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
heroshots
|