llamagen-python 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.
- llamagen/__init__.py +16 -0
- llamagen/_client.py +44 -0
- llamagen/_errors.py +20 -0
- llamagen/_http.py +137 -0
- llamagen/_types.py +138 -0
- llamagen/_webhooks.py +138 -0
- llamagen/py.typed +1 -0
- llamagen/resources/__init__.py +4 -0
- llamagen/resources/animations.py +70 -0
- llamagen/resources/comics.py +308 -0
- llamagen_python-0.1.0.dist-info/METADATA +199 -0
- llamagen_python-0.1.0.dist-info/RECORD +14 -0
- llamagen_python-0.1.0.dist-info/WHEEL +4 -0
- llamagen_python-0.1.0.dist-info/licenses/LICENSE +21 -0
llamagen/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from ._client import LlamaGenClient
|
|
2
|
+
from ._errors import LlamaGenAPIError, LlamaGenTimeoutError, LlamaGenWebhookSignatureError
|
|
3
|
+
from ._types import SUPPORTED_COMIC_SIZES
|
|
4
|
+
from ._webhooks import construct_webhook_event, verify_webhook_signature
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"LlamaGenAPIError",
|
|
8
|
+
"LlamaGenClient",
|
|
9
|
+
"LlamaGenTimeoutError",
|
|
10
|
+
"LlamaGenWebhookSignatureError",
|
|
11
|
+
"SUPPORTED_COMIC_SIZES",
|
|
12
|
+
"construct_webhook_event",
|
|
13
|
+
"verify_webhook_signature",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
llamagen/_client.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from ._http import HTTPClient, Transport
|
|
6
|
+
from .resources.animations import AnimationsResource
|
|
7
|
+
from .resources.comics import ComicsResource
|
|
8
|
+
|
|
9
|
+
DEFAULT_BASE_URL = "https://api.llamagen.ai/v1"
|
|
10
|
+
DEFAULT_TIMEOUT_MS = 30000
|
|
11
|
+
DEFAULT_MAX_RETRIES = 2
|
|
12
|
+
DEFAULT_RETRY_DELAY_MS = 500
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LlamaGenClient:
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
*,
|
|
19
|
+
api_key: str,
|
|
20
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
21
|
+
timeout_ms: int = DEFAULT_TIMEOUT_MS,
|
|
22
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
23
|
+
retry_delay_ms: int = DEFAULT_RETRY_DELAY_MS,
|
|
24
|
+
transport: Optional[Transport] = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
if not api_key:
|
|
27
|
+
raise ValueError("`api_key` is required to initialize LlamaGenClient.")
|
|
28
|
+
|
|
29
|
+
http = HTTPClient(
|
|
30
|
+
api_key=api_key,
|
|
31
|
+
base_url=base_url,
|
|
32
|
+
timeout_ms=timeout_ms,
|
|
33
|
+
max_retries=max_retries,
|
|
34
|
+
retry_delay_ms=retry_delay_ms,
|
|
35
|
+
transport=transport,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
comic = ComicsResource(http)
|
|
39
|
+
animation = AnimationsResource(http)
|
|
40
|
+
|
|
41
|
+
self.comic = comic
|
|
42
|
+
self.comics = comic
|
|
43
|
+
self.animation = animation
|
|
44
|
+
self.animations = animation
|
llamagen/_errors.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class LlamaGenAPIError(Exception):
|
|
7
|
+
"""Raised when the LlamaGen API returns a non-2xx response."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, message: str, status: int, data: Optional[Any] = None) -> None:
|
|
10
|
+
super().__init__(message)
|
|
11
|
+
self.status = status
|
|
12
|
+
self.data = data
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LlamaGenTimeoutError(TimeoutError):
|
|
16
|
+
"""Raised when a request or polling operation times out."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class LlamaGenWebhookSignatureError(ValueError):
|
|
20
|
+
"""Raised when webhook signature verification fails."""
|
llamagen/_http.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import socket
|
|
5
|
+
import time
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
8
|
+
from typing import Any, Dict, Mapping, Optional, Protocol, Tuple
|
|
9
|
+
|
|
10
|
+
from ._errors import LlamaGenAPIError, LlamaGenTimeoutError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Transport(Protocol):
|
|
14
|
+
def request(
|
|
15
|
+
self,
|
|
16
|
+
method: str,
|
|
17
|
+
url: str,
|
|
18
|
+
headers: Mapping[str, str],
|
|
19
|
+
body: Optional[bytes],
|
|
20
|
+
timeout: float,
|
|
21
|
+
) -> Tuple[int, Mapping[str, str], bytes]:
|
|
22
|
+
...
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class UrllibTransport:
|
|
26
|
+
def request(
|
|
27
|
+
self,
|
|
28
|
+
method: str,
|
|
29
|
+
url: str,
|
|
30
|
+
headers: Mapping[str, str],
|
|
31
|
+
body: Optional[bytes],
|
|
32
|
+
timeout: float,
|
|
33
|
+
) -> Tuple[int, Mapping[str, str], bytes]:
|
|
34
|
+
request = urllib.request.Request(url, data=body, method=method, headers=dict(headers))
|
|
35
|
+
try:
|
|
36
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
37
|
+
return response.status, dict(response.headers.items()), response.read()
|
|
38
|
+
except urllib.error.HTTPError as error:
|
|
39
|
+
return error.code, dict(error.headers.items()), error.read()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class HTTPClient:
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
*,
|
|
46
|
+
api_key: str,
|
|
47
|
+
base_url: str,
|
|
48
|
+
timeout_ms: int,
|
|
49
|
+
max_retries: int,
|
|
50
|
+
retry_delay_ms: int,
|
|
51
|
+
transport: Optional[Transport] = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
self.api_key = api_key
|
|
54
|
+
self.base_url = base_url.rstrip("/")
|
|
55
|
+
self.timeout = timeout_ms / 1000
|
|
56
|
+
self.max_retries = max_retries
|
|
57
|
+
self.retry_delay = retry_delay_ms / 1000
|
|
58
|
+
self.transport = transport or UrllibTransport()
|
|
59
|
+
|
|
60
|
+
def request(
|
|
61
|
+
self,
|
|
62
|
+
path: str,
|
|
63
|
+
*,
|
|
64
|
+
method: str = "GET",
|
|
65
|
+
json_body: Optional[Mapping[str, Any]] = None,
|
|
66
|
+
body: Optional[bytes] = None,
|
|
67
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
68
|
+
) -> Any:
|
|
69
|
+
if json_body is not None and body is not None:
|
|
70
|
+
raise ValueError("Pass either json_body or body, not both.")
|
|
71
|
+
|
|
72
|
+
prepared_headers: Dict[str, str] = {"Authorization": f"Bearer {self.api_key}"}
|
|
73
|
+
if json_body is not None:
|
|
74
|
+
body = json.dumps(json_body, separators=(",", ":")).encode("utf-8")
|
|
75
|
+
prepared_headers["Content-Type"] = "application/json"
|
|
76
|
+
if headers:
|
|
77
|
+
prepared_headers.update(headers)
|
|
78
|
+
|
|
79
|
+
attempt = 0
|
|
80
|
+
url = f"{self.base_url}{path}"
|
|
81
|
+
|
|
82
|
+
while True:
|
|
83
|
+
try:
|
|
84
|
+
status, response_headers, response_body = self.transport.request(
|
|
85
|
+
method,
|
|
86
|
+
url,
|
|
87
|
+
prepared_headers,
|
|
88
|
+
body,
|
|
89
|
+
self.timeout,
|
|
90
|
+
)
|
|
91
|
+
except Exception as error:
|
|
92
|
+
if _is_timeout_error(error):
|
|
93
|
+
raise LlamaGenTimeoutError(f"Request timed out after {int(self.timeout * 1000)}ms") from error
|
|
94
|
+
raise
|
|
95
|
+
|
|
96
|
+
data = _decode_response(response_body, response_headers)
|
|
97
|
+
if 200 <= status < 300:
|
|
98
|
+
return data
|
|
99
|
+
|
|
100
|
+
if _should_retry(status) and attempt < self.max_retries:
|
|
101
|
+
attempt += 1
|
|
102
|
+
time.sleep(self.retry_delay * attempt)
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
raise LlamaGenAPIError(f"LlamaGen API request failed with status {status}", status, data)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _decode_response(body: bytes, headers: Mapping[str, str]) -> Any:
|
|
109
|
+
if not body:
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
text = body.decode("utf-8", errors="replace")
|
|
113
|
+
content_type = ""
|
|
114
|
+
for key, value in headers.items():
|
|
115
|
+
if key.lower() == "content-type":
|
|
116
|
+
content_type = value
|
|
117
|
+
break
|
|
118
|
+
|
|
119
|
+
if "application/json" in content_type.lower():
|
|
120
|
+
return json.loads(text)
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
return json.loads(text)
|
|
124
|
+
except json.JSONDecodeError:
|
|
125
|
+
return text
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _should_retry(status: int) -> bool:
|
|
129
|
+
return status == 429 or status >= 500
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _is_timeout_error(error: BaseException) -> bool:
|
|
133
|
+
if isinstance(error, (TimeoutError, socket.timeout)):
|
|
134
|
+
return True
|
|
135
|
+
if isinstance(error, urllib.error.URLError):
|
|
136
|
+
return isinstance(error.reason, (TimeoutError, socket.timeout))
|
|
137
|
+
return False
|
llamagen/_types.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, List, Mapping, MutableMapping, Optional, Sequence, TypedDict, Union
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
SUPPORTED_COMIC_SIZES = (
|
|
7
|
+
"1024x1024",
|
|
8
|
+
"512x768",
|
|
9
|
+
"512x1024",
|
|
10
|
+
"576x1024",
|
|
11
|
+
"768x1024",
|
|
12
|
+
"1024x768",
|
|
13
|
+
"768x512",
|
|
14
|
+
"1024x576",
|
|
15
|
+
"1024x512",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
JSONValue = Union[None, bool, int, float, str, List["JSONValue"], Dict[str, "JSONValue"]]
|
|
19
|
+
Headers = Mapping[str, Union[str, Sequence[str], None]]
|
|
20
|
+
Params = MutableMapping[str, Any]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ComicPagination(TypedDict, total=False):
|
|
24
|
+
totalPages: int
|
|
25
|
+
panelsPerPage: int
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ComicRole(TypedDict, total=False):
|
|
29
|
+
name: str
|
|
30
|
+
image: str
|
|
31
|
+
age: Union[int, str]
|
|
32
|
+
gender: str
|
|
33
|
+
clothing: str
|
|
34
|
+
description: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ComicLocation(TypedDict, total=False):
|
|
38
|
+
name: str
|
|
39
|
+
image: str
|
|
40
|
+
description: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ComicAttachment(TypedDict, total=False):
|
|
44
|
+
url: str
|
|
45
|
+
type: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class CreateComicParams(TypedDict, total=False):
|
|
49
|
+
prompt: str
|
|
50
|
+
size: str
|
|
51
|
+
model: str
|
|
52
|
+
preset: str
|
|
53
|
+
style: str
|
|
54
|
+
promptUrl: str
|
|
55
|
+
fixPanelNum: int
|
|
56
|
+
pagination: ComicPagination
|
|
57
|
+
images: List[str]
|
|
58
|
+
comicRoles: List[ComicRole]
|
|
59
|
+
comicLocations: List[ComicLocation]
|
|
60
|
+
attachments: List[ComicAttachment]
|
|
61
|
+
language: str
|
|
62
|
+
upscale: Union[bool, str]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ContinueComicParams(TypedDict, total=False):
|
|
66
|
+
prompt: str
|
|
67
|
+
fixPanelNum: int
|
|
68
|
+
pagination: ComicPagination
|
|
69
|
+
attachments: List[ComicAttachment]
|
|
70
|
+
images: List[str]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class UpdateComicPanelParams(TypedDict, total=False):
|
|
74
|
+
page: int
|
|
75
|
+
pageIndex: int
|
|
76
|
+
page_index: int
|
|
77
|
+
panel: int
|
|
78
|
+
panelIndex: int
|
|
79
|
+
panel_index: int
|
|
80
|
+
panelPrompt: str
|
|
81
|
+
prompt: str
|
|
82
|
+
panel_prompt: str
|
|
83
|
+
images: List[str]
|
|
84
|
+
images_url: Union[str, List[str]]
|
|
85
|
+
caption: str
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class AnimationVideoOptions(TypedDict, total=False):
|
|
89
|
+
duration: int
|
|
90
|
+
resolution: str
|
|
91
|
+
aspect_ratio: str
|
|
92
|
+
image: str
|
|
93
|
+
last_frame_image: str
|
|
94
|
+
reference_images: List[str]
|
|
95
|
+
reference_videos: List[str]
|
|
96
|
+
reference_audios: List[str]
|
|
97
|
+
generate_audio: bool
|
|
98
|
+
seed: int
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class CreateAnimationParams(TypedDict, total=False):
|
|
102
|
+
prompt: str
|
|
103
|
+
videoOptions: AnimationVideoOptions
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class WaitOptions(TypedDict, total=False):
|
|
107
|
+
interval_ms: int
|
|
108
|
+
timeout_ms: int
|
|
109
|
+
done_statuses: Sequence[str]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
ComicArtworkResponse = Dict[str, Any]
|
|
113
|
+
AnimationArtworkResponse = Dict[str, Any]
|
|
114
|
+
ComicUsage = Dict[str, Any]
|
|
115
|
+
ComicUploadResponse = Dict[str, Any]
|
|
116
|
+
WebhookEvent = Dict[str, Any]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
__all__ = [
|
|
120
|
+
"AnimationArtworkResponse",
|
|
121
|
+
"AnimationVideoOptions",
|
|
122
|
+
"ComicArtworkResponse",
|
|
123
|
+
"ComicAttachment",
|
|
124
|
+
"ComicLocation",
|
|
125
|
+
"ComicPagination",
|
|
126
|
+
"ComicRole",
|
|
127
|
+
"ComicUploadResponse",
|
|
128
|
+
"ComicUsage",
|
|
129
|
+
"ContinueComicParams",
|
|
130
|
+
"CreateAnimationParams",
|
|
131
|
+
"CreateComicParams",
|
|
132
|
+
"Headers",
|
|
133
|
+
"Params",
|
|
134
|
+
"SUPPORTED_COMIC_SIZES",
|
|
135
|
+
"UpdateComicPanelParams",
|
|
136
|
+
"WaitOptions",
|
|
137
|
+
"WebhookEvent",
|
|
138
|
+
]
|
llamagen/_webhooks.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any, Mapping, Optional, Sequence, Union
|
|
8
|
+
|
|
9
|
+
from ._errors import LlamaGenWebhookSignatureError
|
|
10
|
+
from ._types import Headers, WebhookEvent
|
|
11
|
+
|
|
12
|
+
DEFAULT_TOLERANCE_SECONDS = 300
|
|
13
|
+
Payload = Union[str, bytes, bytearray]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def verify_webhook_signature(
|
|
17
|
+
payload: Payload,
|
|
18
|
+
headers: Headers,
|
|
19
|
+
secret: str,
|
|
20
|
+
*,
|
|
21
|
+
tolerance_seconds: int = DEFAULT_TOLERANCE_SECONDS,
|
|
22
|
+
now: Optional[int] = None,
|
|
23
|
+
) -> bool:
|
|
24
|
+
try:
|
|
25
|
+
_assert_valid_signature(
|
|
26
|
+
payload,
|
|
27
|
+
headers,
|
|
28
|
+
secret,
|
|
29
|
+
tolerance_seconds=tolerance_seconds,
|
|
30
|
+
now=now,
|
|
31
|
+
)
|
|
32
|
+
return True
|
|
33
|
+
except LlamaGenWebhookSignatureError:
|
|
34
|
+
return False
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def construct_webhook_event(
|
|
38
|
+
payload: Payload,
|
|
39
|
+
headers: Headers,
|
|
40
|
+
secret: str,
|
|
41
|
+
*,
|
|
42
|
+
tolerance_seconds: int = DEFAULT_TOLERANCE_SECONDS,
|
|
43
|
+
now: Optional[int] = None,
|
|
44
|
+
) -> WebhookEvent:
|
|
45
|
+
_assert_valid_signature(
|
|
46
|
+
payload,
|
|
47
|
+
headers,
|
|
48
|
+
secret,
|
|
49
|
+
tolerance_seconds=tolerance_seconds,
|
|
50
|
+
now=now,
|
|
51
|
+
)
|
|
52
|
+
body = _payload_bytes(payload).decode("utf-8")
|
|
53
|
+
event = json.loads(body)
|
|
54
|
+
request_id = _get_header(headers, "x-llama-webhook-request-id")
|
|
55
|
+
event_id = event.get("id") or _get_header(headers, "x-llama-webhook-id")
|
|
56
|
+
if event_id:
|
|
57
|
+
event["id"] = event_id
|
|
58
|
+
if request_id:
|
|
59
|
+
event["requestId"] = request_id
|
|
60
|
+
return event
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _assert_valid_signature(
|
|
64
|
+
payload: Payload,
|
|
65
|
+
headers: Headers,
|
|
66
|
+
secret: str,
|
|
67
|
+
*,
|
|
68
|
+
tolerance_seconds: int,
|
|
69
|
+
now: Optional[int],
|
|
70
|
+
) -> None:
|
|
71
|
+
if not secret:
|
|
72
|
+
raise LlamaGenWebhookSignatureError("Webhook secret is required.")
|
|
73
|
+
|
|
74
|
+
timestamp = _get_header(headers, "x-llama-webhook-timestamp")
|
|
75
|
+
signature = _get_header(headers, "x-llama-webhook-signature")
|
|
76
|
+
if not timestamp or not signature:
|
|
77
|
+
raise LlamaGenWebhookSignatureError("Missing LlamaGen webhook signature headers.")
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
parsed_timestamp = int(timestamp)
|
|
81
|
+
except ValueError as error:
|
|
82
|
+
raise LlamaGenWebhookSignatureError("Invalid LlamaGen webhook timestamp.") from error
|
|
83
|
+
|
|
84
|
+
current_time = int(time.time()) if now is None else now
|
|
85
|
+
if tolerance_seconds > 0 and abs(current_time - parsed_timestamp) > tolerance_seconds:
|
|
86
|
+
raise LlamaGenWebhookSignatureError("LlamaGen webhook timestamp is outside tolerance.")
|
|
87
|
+
|
|
88
|
+
expected = _sign_payload(payload, secret, timestamp)
|
|
89
|
+
candidates = [
|
|
90
|
+
part.strip().removeprefix("v1=")
|
|
91
|
+
for part in signature.split(",")
|
|
92
|
+
if part.strip()
|
|
93
|
+
]
|
|
94
|
+
if not any(hmac.compare_digest(candidate, expected) for candidate in candidates):
|
|
95
|
+
raise LlamaGenWebhookSignatureError("No matching LlamaGen webhook signature found.")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _sign_payload(payload: Payload, secret: str, timestamp: str) -> str:
|
|
99
|
+
body = _payload_bytes(payload)
|
|
100
|
+
signer = hmac.new(secret.encode("utf-8"), digestmod=hashlib.sha256)
|
|
101
|
+
signer.update(f"{timestamp}.".encode("utf-8"))
|
|
102
|
+
signer.update(body)
|
|
103
|
+
return signer.hexdigest()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _payload_bytes(payload: Payload) -> bytes:
|
|
107
|
+
if isinstance(payload, bytes):
|
|
108
|
+
return payload
|
|
109
|
+
if isinstance(payload, bytearray):
|
|
110
|
+
return bytes(payload)
|
|
111
|
+
return payload.encode("utf-8")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _get_header(headers: Headers, name: str) -> Optional[str]:
|
|
115
|
+
lower = name.lower()
|
|
116
|
+
getter = getattr(headers, "get", None)
|
|
117
|
+
if callable(getter):
|
|
118
|
+
direct = getter(name) or getter(lower)
|
|
119
|
+
if direct is not None:
|
|
120
|
+
return _header_value_to_string(direct)
|
|
121
|
+
|
|
122
|
+
if isinstance(headers, Mapping):
|
|
123
|
+
for key, value in headers.items():
|
|
124
|
+
if key.lower() == lower:
|
|
125
|
+
return _header_value_to_string(value)
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _header_value_to_string(value: Union[str, Sequence[str], None]) -> Optional[str]:
|
|
130
|
+
if value is None:
|
|
131
|
+
return None
|
|
132
|
+
if isinstance(value, str):
|
|
133
|
+
return value
|
|
134
|
+
return ",".join(value)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
verifyWebhookSignature = verify_webhook_signature
|
|
138
|
+
constructWebhookEvent = construct_webhook_event
|
llamagen/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Mapping, Optional, Sequence
|
|
5
|
+
|
|
6
|
+
from .._errors import LlamaGenTimeoutError
|
|
7
|
+
from .._http import HTTPClient
|
|
8
|
+
from .._types import AnimationArtworkResponse
|
|
9
|
+
|
|
10
|
+
DEFAULT_DONE_STATUSES = ("SUCCEEDED", "FAILED", "PROCESSED", "COMPLETED", "CANCELLED")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AnimationsResource:
|
|
14
|
+
def __init__(self, http: HTTPClient) -> None:
|
|
15
|
+
self._http = http
|
|
16
|
+
|
|
17
|
+
def create(self, params: Mapping[str, object]) -> AnimationArtworkResponse:
|
|
18
|
+
_assert_non_empty(params.get("prompt") if params else None, "`prompt` is required and must be a non-empty string.")
|
|
19
|
+
return self._http.request("/artworks/generations", method="POST", json_body=params)
|
|
20
|
+
|
|
21
|
+
def get(self, artwork_id: str) -> AnimationArtworkResponse:
|
|
22
|
+
_assert_non_empty(artwork_id, "`artwork_id` is required and must be a non-empty string.")
|
|
23
|
+
return self._http.request(f"/artworks/generations/{artwork_id}", method="GET")
|
|
24
|
+
|
|
25
|
+
def wait_for_completion(
|
|
26
|
+
self,
|
|
27
|
+
artwork_id: str,
|
|
28
|
+
*,
|
|
29
|
+
interval_ms: int = 5000,
|
|
30
|
+
timeout_ms: int = 300000,
|
|
31
|
+
done_statuses: Optional[Sequence[str]] = None,
|
|
32
|
+
) -> AnimationArtworkResponse:
|
|
33
|
+
done = {status.upper() for status in (done_statuses or DEFAULT_DONE_STATUSES)}
|
|
34
|
+
start = time.monotonic()
|
|
35
|
+
|
|
36
|
+
while True:
|
|
37
|
+
result = self.get(artwork_id)
|
|
38
|
+
if str(result.get("status", "")).upper() in done:
|
|
39
|
+
return result
|
|
40
|
+
|
|
41
|
+
if (time.monotonic() - start) * 1000 >= timeout_ms:
|
|
42
|
+
raise LlamaGenTimeoutError(
|
|
43
|
+
f"Artwork {artwork_id} did not reach a completed status within {timeout_ms}ms"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
time.sleep(interval_ms / 1000)
|
|
47
|
+
|
|
48
|
+
def create_and_wait(
|
|
49
|
+
self,
|
|
50
|
+
params: Mapping[str, object],
|
|
51
|
+
*,
|
|
52
|
+
interval_ms: int = 5000,
|
|
53
|
+
timeout_ms: int = 300000,
|
|
54
|
+
done_statuses: Optional[Sequence[str]] = None,
|
|
55
|
+
) -> AnimationArtworkResponse:
|
|
56
|
+
created = self.create(params)
|
|
57
|
+
return self.wait_for_completion(
|
|
58
|
+
created["id"],
|
|
59
|
+
interval_ms=interval_ms,
|
|
60
|
+
timeout_ms=timeout_ms,
|
|
61
|
+
done_statuses=done_statuses,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
waitForCompletion = wait_for_completion
|
|
65
|
+
createAndWait = create_and_wait
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _assert_non_empty(value: object, message: str) -> None:
|
|
69
|
+
if not isinstance(value, str) or not value.strip():
|
|
70
|
+
raise TypeError(message)
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import concurrent.futures
|
|
4
|
+
import mimetypes
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, BinaryIO, Dict, Iterable, List, Mapping, Optional, Sequence, Union
|
|
10
|
+
from urllib.parse import urlencode
|
|
11
|
+
|
|
12
|
+
from .._errors import LlamaGenTimeoutError
|
|
13
|
+
from .._http import HTTPClient
|
|
14
|
+
from .._types import ComicArtworkResponse, ComicUploadResponse, ComicUsage, SUPPORTED_COMIC_SIZES
|
|
15
|
+
|
|
16
|
+
DEFAULT_SIZE = "1024x1024"
|
|
17
|
+
DEFAULT_PRESET = "neutral"
|
|
18
|
+
DEFAULT_DONE_STATUSES = ("SUCCEEDED", "FAILED", "PROCESSED", "COMPLETED", "CANCELLED")
|
|
19
|
+
SUPPORTED_COMIC_SIZE_SET = set(SUPPORTED_COMIC_SIZES)
|
|
20
|
+
FileInput = Union[str, os.PathLike[str], bytes, bytearray, BinaryIO]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ComicsResource:
|
|
24
|
+
def __init__(self, http: HTTPClient) -> None:
|
|
25
|
+
self._http = http
|
|
26
|
+
|
|
27
|
+
def create(self, params: Mapping[str, Any]) -> ComicArtworkResponse:
|
|
28
|
+
_assert_non_empty(params.get("prompt") if params else None, "`prompt` is required and must be a non-empty string.")
|
|
29
|
+
if params.get("size") is not None:
|
|
30
|
+
_assert_supported_size(str(params["size"]))
|
|
31
|
+
_assert_layout_options(params)
|
|
32
|
+
|
|
33
|
+
body: Dict[str, Any] = {"preset": DEFAULT_PRESET, "size": DEFAULT_SIZE}
|
|
34
|
+
body.update(params)
|
|
35
|
+
return self._http.request("/comics/generations", method="POST", json_body=body)
|
|
36
|
+
|
|
37
|
+
def get(self, artwork_id: str, *, page: Optional[int] = None, panel: Optional[int] = None) -> ComicArtworkResponse:
|
|
38
|
+
_assert_non_empty(artwork_id, "`artwork_id` is required and must be a non-empty string.")
|
|
39
|
+
query: Dict[str, int] = {}
|
|
40
|
+
if page is not None:
|
|
41
|
+
_assert_non_negative_integer(page, "`page` must be a non-negative integer.")
|
|
42
|
+
query["page"] = page
|
|
43
|
+
if panel is not None:
|
|
44
|
+
_assert_non_negative_integer(panel, "`panel` must be a non-negative integer.")
|
|
45
|
+
query["panel"] = panel
|
|
46
|
+
|
|
47
|
+
suffix = f"?{urlencode(query)}" if query else ""
|
|
48
|
+
return self._http.request(f"/comics/generations/{artwork_id}{suffix}", method="GET")
|
|
49
|
+
|
|
50
|
+
def continue_write(self, generation_id: str, params: Mapping[str, Any]) -> ComicArtworkResponse:
|
|
51
|
+
_assert_non_empty(generation_id, "`generation_id` is required and must be a non-empty string.")
|
|
52
|
+
_assert_non_empty(params.get("prompt") if params else None, "`prompt` is required and must be a non-empty string.")
|
|
53
|
+
_assert_layout_options(params)
|
|
54
|
+
body = dict(params)
|
|
55
|
+
body["action"] = "continueWrite"
|
|
56
|
+
return self._http.request(f"/comics/generations/{generation_id}", method="PATCH", json_body=body)
|
|
57
|
+
|
|
58
|
+
def update_panel(self, generation_id: str, params: Mapping[str, Any]) -> ComicArtworkResponse:
|
|
59
|
+
_assert_non_empty(generation_id, "`generation_id` is required and must be a non-empty string.")
|
|
60
|
+
_assert_panel_locator(params)
|
|
61
|
+
_assert_panel_update_payload(params)
|
|
62
|
+
body = dict(params)
|
|
63
|
+
body["action"] = "regeneratePanel"
|
|
64
|
+
return self._http.request(f"/comics/generations/{generation_id}", method="PATCH", json_body=body)
|
|
65
|
+
|
|
66
|
+
def usage(self) -> ComicUsage:
|
|
67
|
+
return self._http.request("/comics/usage", method="GET")
|
|
68
|
+
|
|
69
|
+
def upload(
|
|
70
|
+
self,
|
|
71
|
+
file: FileInput,
|
|
72
|
+
filename: Optional[str] = None,
|
|
73
|
+
*,
|
|
74
|
+
content_type: Optional[str] = None,
|
|
75
|
+
) -> ComicUploadResponse:
|
|
76
|
+
payload, resolved_name = _read_file_input(file, filename)
|
|
77
|
+
multipart_body, multipart_content_type = _build_multipart(
|
|
78
|
+
field_name="file",
|
|
79
|
+
filename=resolved_name,
|
|
80
|
+
payload=payload,
|
|
81
|
+
content_type=content_type or mimetypes.guess_type(resolved_name)[0] or "application/octet-stream",
|
|
82
|
+
)
|
|
83
|
+
return self._http.request(
|
|
84
|
+
"/comics/upload",
|
|
85
|
+
method="POST",
|
|
86
|
+
body=multipart_body,
|
|
87
|
+
headers={"Content-Type": multipart_content_type},
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def wait_for_completion(
|
|
91
|
+
self,
|
|
92
|
+
artwork_id: str,
|
|
93
|
+
*,
|
|
94
|
+
interval_ms: int = 5000,
|
|
95
|
+
timeout_ms: int = 180000,
|
|
96
|
+
done_statuses: Optional[Sequence[str]] = None,
|
|
97
|
+
) -> ComicArtworkResponse:
|
|
98
|
+
done = {status.upper() for status in (done_statuses or DEFAULT_DONE_STATUSES)}
|
|
99
|
+
start = time.monotonic()
|
|
100
|
+
|
|
101
|
+
while True:
|
|
102
|
+
result = self.get(artwork_id)
|
|
103
|
+
if str(result.get("status", "")).upper() in done:
|
|
104
|
+
return result
|
|
105
|
+
|
|
106
|
+
if (time.monotonic() - start) * 1000 >= timeout_ms:
|
|
107
|
+
raise LlamaGenTimeoutError(
|
|
108
|
+
f"Artwork {artwork_id} did not reach a completed status within {timeout_ms}ms"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
time.sleep(interval_ms / 1000)
|
|
112
|
+
|
|
113
|
+
def create_and_wait(
|
|
114
|
+
self,
|
|
115
|
+
params: Mapping[str, Any],
|
|
116
|
+
*,
|
|
117
|
+
interval_ms: int = 5000,
|
|
118
|
+
timeout_ms: int = 180000,
|
|
119
|
+
done_statuses: Optional[Sequence[str]] = None,
|
|
120
|
+
) -> ComicArtworkResponse:
|
|
121
|
+
created = self.create(params)
|
|
122
|
+
return self.wait_for_completion(
|
|
123
|
+
created["id"],
|
|
124
|
+
interval_ms=interval_ms,
|
|
125
|
+
timeout_ms=timeout_ms,
|
|
126
|
+
done_statuses=done_statuses,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def create_batch(
|
|
130
|
+
self,
|
|
131
|
+
params_list: Sequence[Mapping[str, Any]],
|
|
132
|
+
*,
|
|
133
|
+
concurrency: int = 3,
|
|
134
|
+
stop_on_error: bool = False,
|
|
135
|
+
) -> List[Dict[str, Any]]:
|
|
136
|
+
if not params_list:
|
|
137
|
+
raise TypeError("`params_list` must be a non-empty sequence.")
|
|
138
|
+
|
|
139
|
+
def worker(params: Mapping[str, Any]) -> Dict[str, Any]:
|
|
140
|
+
try:
|
|
141
|
+
return {"input": params, "result": self.create(params)}
|
|
142
|
+
except Exception as error:
|
|
143
|
+
if stop_on_error:
|
|
144
|
+
raise
|
|
145
|
+
return {"input": params, "error": error}
|
|
146
|
+
|
|
147
|
+
return _run_with_concurrency(params_list, max(1, concurrency), worker)
|
|
148
|
+
|
|
149
|
+
def wait_for_many(
|
|
150
|
+
self,
|
|
151
|
+
artwork_ids: Sequence[str],
|
|
152
|
+
*,
|
|
153
|
+
concurrency: int = 3,
|
|
154
|
+
interval_ms: int = 5000,
|
|
155
|
+
timeout_ms: int = 180000,
|
|
156
|
+
done_statuses: Optional[Sequence[str]] = None,
|
|
157
|
+
) -> List[ComicArtworkResponse]:
|
|
158
|
+
if not artwork_ids:
|
|
159
|
+
raise TypeError("`artwork_ids` must be a non-empty sequence.")
|
|
160
|
+
|
|
161
|
+
def worker(artwork_id: str) -> ComicArtworkResponse:
|
|
162
|
+
return self.wait_for_completion(
|
|
163
|
+
artwork_id,
|
|
164
|
+
interval_ms=interval_ms,
|
|
165
|
+
timeout_ms=timeout_ms,
|
|
166
|
+
done_statuses=done_statuses,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
return _run_with_concurrency(artwork_ids, max(1, concurrency), worker)
|
|
170
|
+
|
|
171
|
+
createComic = create
|
|
172
|
+
getComic = get
|
|
173
|
+
continueComic = continue_write
|
|
174
|
+
continueWrite = continue_write
|
|
175
|
+
regeneratePanel = update_panel
|
|
176
|
+
updateComicPanel = update_panel
|
|
177
|
+
updatePanel = update_panel
|
|
178
|
+
waitForCompletion = wait_for_completion
|
|
179
|
+
createAndWait = create_and_wait
|
|
180
|
+
createBatch = create_batch
|
|
181
|
+
waitForMany = wait_for_many
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _run_with_concurrency(items: Sequence[Any], concurrency: int, worker: Any) -> List[Any]:
|
|
185
|
+
results: List[Any] = [None] * len(items)
|
|
186
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=min(concurrency, len(items))) as executor:
|
|
187
|
+
future_to_index = {executor.submit(worker, item): index for index, item in enumerate(items)}
|
|
188
|
+
for future in concurrent.futures.as_completed(future_to_index):
|
|
189
|
+
results[future_to_index[future]] = future.result()
|
|
190
|
+
return results
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _read_file_input(file: FileInput, filename: Optional[str]) -> tuple[bytes, str]:
|
|
194
|
+
if isinstance(file, (str, os.PathLike)):
|
|
195
|
+
path = Path(file)
|
|
196
|
+
return path.read_bytes(), filename or path.name
|
|
197
|
+
if isinstance(file, (bytes, bytearray)):
|
|
198
|
+
return bytes(file), filename or "upload"
|
|
199
|
+
data = file.read()
|
|
200
|
+
if isinstance(data, str):
|
|
201
|
+
data = data.encode("utf-8")
|
|
202
|
+
resolved_name = filename or Path(getattr(file, "name", "upload")).name
|
|
203
|
+
return data, resolved_name
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _build_multipart(*, field_name: str, filename: str, payload: bytes, content_type: str) -> tuple[bytes, str]:
|
|
207
|
+
boundary = f"----llamagen-{uuid.uuid4().hex}"
|
|
208
|
+
chunks = [
|
|
209
|
+
f"--{boundary}\r\n".encode("utf-8"),
|
|
210
|
+
(
|
|
211
|
+
f'Content-Disposition: form-data; name="{field_name}"; filename="{filename}"\r\n'
|
|
212
|
+
f"Content-Type: {content_type}\r\n\r\n"
|
|
213
|
+
).encode("utf-8"),
|
|
214
|
+
payload,
|
|
215
|
+
b"\r\n",
|
|
216
|
+
f"--{boundary}--\r\n".encode("utf-8"),
|
|
217
|
+
]
|
|
218
|
+
return b"".join(chunks), f"multipart/form-data; boundary={boundary}"
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _assert_supported_size(value: str) -> None:
|
|
222
|
+
if value not in SUPPORTED_COMIC_SIZE_SET:
|
|
223
|
+
raise TypeError(f"`size` must be one of: {', '.join(SUPPORTED_COMIC_SIZES)}. Received: {value}")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _assert_layout_options(params: Mapping[str, Any]) -> None:
|
|
227
|
+
if params.get("fixPanelNum") is not None and params.get("pagination") is not None:
|
|
228
|
+
raise TypeError("`fixPanelNum` and `pagination` cannot be used together.")
|
|
229
|
+
|
|
230
|
+
if params.get("fixPanelNum") is not None:
|
|
231
|
+
_assert_integer_in_range(params["fixPanelNum"], 1, 20, "`fixPanelNum` must be an integer from 1 to 20.")
|
|
232
|
+
|
|
233
|
+
if params.get("pagination") is not None:
|
|
234
|
+
pagination = params["pagination"]
|
|
235
|
+
if not isinstance(pagination, Mapping):
|
|
236
|
+
raise TypeError("`pagination` must be a mapping.")
|
|
237
|
+
_assert_integer_in_range(
|
|
238
|
+
pagination.get("totalPages"),
|
|
239
|
+
1,
|
|
240
|
+
20,
|
|
241
|
+
"`pagination.totalPages` must be an integer from 1 to 20.",
|
|
242
|
+
)
|
|
243
|
+
_assert_integer_in_range(
|
|
244
|
+
pagination.get("panelsPerPage"),
|
|
245
|
+
1,
|
|
246
|
+
20,
|
|
247
|
+
"`pagination.panelsPerPage` must be an integer from 1 to 20.",
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _assert_panel_locator(params: Mapping[str, Any]) -> None:
|
|
252
|
+
panel = _first_defined(params, "panel", "panelIndex", "panel_index")
|
|
253
|
+
if panel is None:
|
|
254
|
+
raise TypeError("`panel`, `panelIndex`, or `panel_index` is required.")
|
|
255
|
+
_assert_non_negative_integer(panel, "`panel` must be a non-negative integer.")
|
|
256
|
+
|
|
257
|
+
page = _first_defined(params, "page", "pageIndex", "page_index")
|
|
258
|
+
if page is not None:
|
|
259
|
+
_assert_non_negative_integer(page, "`page` must be a non-negative integer.")
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _assert_panel_update_payload(params: Mapping[str, Any]) -> None:
|
|
263
|
+
has_payload = (
|
|
264
|
+
_has_non_empty_string(params.get("panelPrompt"))
|
|
265
|
+
or _has_non_empty_string(params.get("prompt"))
|
|
266
|
+
or _has_non_empty_string(params.get("panel_prompt"))
|
|
267
|
+
or _has_non_empty_string(params.get("caption"))
|
|
268
|
+
or _has_images(params.get("images"))
|
|
269
|
+
or _has_image_alias(params.get("images_url"))
|
|
270
|
+
)
|
|
271
|
+
if not has_payload:
|
|
272
|
+
raise TypeError("One of `panelPrompt`, `prompt`, `images`, or `caption` is required.")
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _first_defined(params: Mapping[str, Any], *keys: str) -> Any:
|
|
276
|
+
for key in keys:
|
|
277
|
+
if params.get(key) is not None:
|
|
278
|
+
return params[key]
|
|
279
|
+
return None
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _assert_non_empty(value: object, message: str) -> None:
|
|
283
|
+
if not isinstance(value, str) or not value.strip():
|
|
284
|
+
raise TypeError(message)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _assert_non_negative_integer(value: object, message: str) -> None:
|
|
288
|
+
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
|
289
|
+
raise TypeError(message)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _assert_integer_in_range(value: object, minimum: int, maximum: int, message: str) -> None:
|
|
293
|
+
if not isinstance(value, int) or isinstance(value, bool) or value < minimum or value > maximum:
|
|
294
|
+
raise TypeError(message)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _has_non_empty_string(value: object) -> bool:
|
|
298
|
+
return isinstance(value, str) and bool(value.strip())
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _has_images(value: object) -> bool:
|
|
302
|
+
return isinstance(value, Iterable) and not isinstance(value, (str, bytes, bytearray, Mapping)) and any(
|
|
303
|
+
_has_non_empty_string(item) for item in value
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _has_image_alias(value: object) -> bool:
|
|
308
|
+
return _has_non_empty_string(value) or _has_images(value)
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: llamagen-python
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for LlamaGen Comic API and Animation API
|
|
5
|
+
Project-URL: Homepage, https://llamagen.ai/comic-api
|
|
6
|
+
Project-URL: Documentation, https://llamagen.ai/comic-api/docs
|
|
7
|
+
Project-URL: Source, https://github.com/LlamaGenAI/llamagen-python
|
|
8
|
+
Project-URL: Issues, https://github.com/LlamaGenAI/llamagen-python/issues
|
|
9
|
+
Author-email: "llamagen.ai" <support@llamagen.ai>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: ai,ai comic api,animation,comic,image-to-video,llamagen,sdk,text-to-video
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
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 :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# llamagen-python
|
|
27
|
+
|
|
28
|
+
Official Python SDK for LlamaGen's Comic API and Animation API.
|
|
29
|
+
|
|
30
|
+
Homepage: <https://llamagen.ai/comic-api>
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install llamagen-python
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Quick Start
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
import os
|
|
42
|
+
from llamagen import LlamaGenClient
|
|
43
|
+
|
|
44
|
+
llamagen = LlamaGenClient(api_key=os.environ["LLAMAGEN_API_KEY"])
|
|
45
|
+
|
|
46
|
+
created = llamagen.comic.create({
|
|
47
|
+
"prompt": "A 4-panel comic about Leo finding a glowing key in a quiet library.",
|
|
48
|
+
"style": "manga",
|
|
49
|
+
"fixPanelNum": 4,
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
done = llamagen.comic.wait_for_completion(created["id"])
|
|
53
|
+
print(done["status"], done.get("comics"))
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Client
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
llamagen = LlamaGenClient(
|
|
60
|
+
api_key="YOUR_API_KEY",
|
|
61
|
+
base_url="https://api.llamagen.ai/v1",
|
|
62
|
+
timeout_ms=30000,
|
|
63
|
+
max_retries=2,
|
|
64
|
+
retry_delay_ms=500,
|
|
65
|
+
)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The SDK uses Python's standard library HTTP stack and does not require runtime
|
|
69
|
+
dependencies.
|
|
70
|
+
|
|
71
|
+
## Comic API
|
|
72
|
+
|
|
73
|
+
Use `llamagen.comic` to create comics, manga, webtoons, storyboards,
|
|
74
|
+
consistent-character pages, and panel edits.
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
generation = llamagen.comic.create({
|
|
78
|
+
"prompt": "The Little Prince meets the Fox in a luminous desert.",
|
|
79
|
+
"style": "storybook manga",
|
|
80
|
+
"size": "1024x1024",
|
|
81
|
+
"pagination": {"totalPages": 2, "panelsPerPage": 4},
|
|
82
|
+
"comicRoles": [
|
|
83
|
+
{
|
|
84
|
+
"name": "The Little Prince",
|
|
85
|
+
"image": "https://example.com/prince.png",
|
|
86
|
+
"clothing": "green coat and yellow scarf",
|
|
87
|
+
}
|
|
88
|
+
],
|
|
89
|
+
"attachments": [{"type": "image", "url": "https://example.com/reference.png"}],
|
|
90
|
+
"language": "en",
|
|
91
|
+
"upscale": "2K",
|
|
92
|
+
})
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Comic methods:
|
|
96
|
+
|
|
97
|
+
- `llamagen.comic.create(params)` calls `POST /v1/comics/generations`.
|
|
98
|
+
- `llamagen.comic.get(id, page=None, panel=None)` calls `GET /v1/comics/generations/{id}`.
|
|
99
|
+
- `llamagen.comic.continue_write(id, params)` extends an existing comic.
|
|
100
|
+
- `llamagen.comic.update_panel(id, params)` regenerates one panel.
|
|
101
|
+
- `llamagen.comic.usage()` calls `GET /v1/comics/usage`.
|
|
102
|
+
- `llamagen.comic.upload(file, filename=None)` calls `POST /v1/comics/upload`.
|
|
103
|
+
- `llamagen.comic.wait_for_completion(id, **options)` polls until a terminal status.
|
|
104
|
+
- `llamagen.comic.create_and_wait(params, **options)` creates and polls.
|
|
105
|
+
- `llamagen.comic.create_batch(params_list, concurrency=3)` submits many jobs.
|
|
106
|
+
- `llamagen.comic.wait_for_many(ids, concurrency=3)` polls many jobs.
|
|
107
|
+
|
|
108
|
+
JavaScript-style aliases are also available: `createComic`, `getComic`,
|
|
109
|
+
`continueComic`, `regeneratePanel`, `updateComicPanel`, `waitForCompletion`,
|
|
110
|
+
`createAndWait`, and `waitForMany`.
|
|
111
|
+
|
|
112
|
+
## Animation API
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
video = llamagen.animation.create({
|
|
116
|
+
"prompt": "A heroic fox detective walks through neon rain, cinematic camera move.",
|
|
117
|
+
"videoOptions": {
|
|
118
|
+
"duration": 5,
|
|
119
|
+
"resolution": "720p",
|
|
120
|
+
"aspect_ratio": "16:9",
|
|
121
|
+
"image": "https://example.com/first-frame.png",
|
|
122
|
+
"last_frame_image": "https://example.com/last-frame.png",
|
|
123
|
+
"reference_images": ["https://example.com/character.png"],
|
|
124
|
+
},
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
finished = llamagen.animation.wait_for_completion(
|
|
128
|
+
video["id"],
|
|
129
|
+
interval_ms=5000,
|
|
130
|
+
timeout_ms=300000,
|
|
131
|
+
)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Animation methods:
|
|
135
|
+
|
|
136
|
+
- `llamagen.animation.create(params)` calls `POST /v1/artworks/generations`.
|
|
137
|
+
- `llamagen.animation.get(id)` calls `GET /v1/artworks/generations/{id}`.
|
|
138
|
+
- `llamagen.animation.wait_for_completion(id, **options)` polls video status.
|
|
139
|
+
- `llamagen.animation.create_and_wait(params, **options)` creates and polls.
|
|
140
|
+
|
|
141
|
+
`llamagen.animations` is an alias for `llamagen.animation`.
|
|
142
|
+
|
|
143
|
+
## Webhooks
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
from llamagen import construct_webhook_event
|
|
147
|
+
|
|
148
|
+
event = construct_webhook_event(
|
|
149
|
+
payload=raw_body,
|
|
150
|
+
headers=request_headers,
|
|
151
|
+
secret=os.environ["LLAMAGEN_WEBHOOK_SECRET"],
|
|
152
|
+
)
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
The helper verifies `X-Llama-Webhook-Timestamp` and
|
|
156
|
+
`X-Llama-Webhook-Signature` with HMAC SHA-256 and a default 5-minute tolerance.
|
|
157
|
+
|
|
158
|
+
## Publishing
|
|
159
|
+
|
|
160
|
+
Detailed release notes are in [`docs/RELEASE.md`](docs/RELEASE.md).
|
|
161
|
+
|
|
162
|
+
1. Update `version` in `pyproject.toml`.
|
|
163
|
+
2. Run tests and packaging checks:
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
python -m unittest discover -s tests
|
|
167
|
+
python -m pip install --upgrade build twine
|
|
168
|
+
python -m build
|
|
169
|
+
python -m twine check dist/*
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
3. Publish to TestPyPI first:
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
python -m twine upload --repository testpypi dist/*
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
4. Install from TestPyPI in a clean environment and smoke-test:
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
python -m pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ llamagen-python
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
5. Publish to PyPI:
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
python -m twine upload dist/*
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Use a PyPI API token through `TWINE_USERNAME=__token__` and
|
|
191
|
+
`TWINE_PASSWORD=pypi-...` or configure a trusted publisher in PyPI for GitHub
|
|
192
|
+
Actions.
|
|
193
|
+
|
|
194
|
+
## Local Dev
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
python -m unittest discover -s tests
|
|
198
|
+
python -m compileall src tests
|
|
199
|
+
```
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
llamagen/__init__.py,sha256=osyUmWp3zU978J28oTJyF2mkS9F_hczoDOhRLHYaghc,483
|
|
2
|
+
llamagen/_client.py,sha256=V7t7dQF4aHWpIhxaXpa8KxTpfAmMbd7AGgWJzNLF2KM,1238
|
|
3
|
+
llamagen/_errors.py,sha256=R00heV5tr1iLyA2ICjFYG0PA9uhEdqkjdHdiFgWHYGc,568
|
|
4
|
+
llamagen/_http.py,sha256=Pq80dU768eW6KXINMFe3AC6cXnlA-fePrNbbDhkuK1o,4177
|
|
5
|
+
llamagen/_types.py,sha256=o7dYuEA6w9m12vFFOWOafH_bPotAHiWvVhmCO0o8tTw,2909
|
|
6
|
+
llamagen/_webhooks.py,sha256=wqW1ZDgJzkw5ntgSRcVf5Nu8FO9QpbcKH0YEuW9m_PA,4073
|
|
7
|
+
llamagen/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
8
|
+
llamagen/resources/__init__.py,sha256=UEj0KBEmRdLJ7YIrGQU9EA5e0HMcVj93ilje_jBHnpY,130
|
|
9
|
+
llamagen/resources/animations.py,sha256=7jAU4j3JRq37wsRyWzByvtL72o_Uc8H1pMeuAp7hr_k,2444
|
|
10
|
+
llamagen/resources/comics.py,sha256=G5O7uDhotS693YhcZZNGU8W6M6rGEItGd2LCVtLdg7E,11871
|
|
11
|
+
llamagen_python-0.1.0.dist-info/METADATA,sha256=fC2rahmB1SnjedNghaq4ren9Oct5b2cUk1E8swOUpUc,5875
|
|
12
|
+
llamagen_python-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
13
|
+
llamagen_python-0.1.0.dist-info/licenses/LICENSE,sha256=sXRfe02p4AEZbNiSUd4r4V8peINjbArVYj921Lx7M-U,1068
|
|
14
|
+
llamagen_python-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 llamagen.ai
|
|
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.
|