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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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