loopcut 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.
- loopcut/__init__.py +5 -0
- loopcut/__main__.py +8 -0
- loopcut/addon/VENDORED.md +21 -0
- loopcut/addon/__init__.py +8 -0
- loopcut/addon/blobs.py +444 -0
- loopcut/addon/bundles.py +569 -0
- loopcut/addon/cloud_http.py +256 -0
- loopcut/addon/config.py +5 -0
- loopcut/addon/credentials.py +6 -0
- loopcut/agent_tools.json +37 -0
- loopcut/catalog.json +955 -0
- loopcut/catalog.py +90 -0
- loopcut/cli.py +265 -0
- loopcut/mcp_server.py +340 -0
- loopcut/pack.py +154 -0
- loopcut/service.py +839 -0
- loopcut/state.py +81 -0
- loopcut-0.1.0.dist-info/METADATA +198 -0
- loopcut-0.1.0.dist-info/RECORD +23 -0
- loopcut-0.1.0.dist-info/WHEEL +5 -0
- loopcut-0.1.0.dist-info/entry_points.txt +2 -0
- loopcut-0.1.0.dist-info/licenses/LICENSE +674 -0
- loopcut-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""The service contract in one place, independent of Blender's UI and worker threads.
|
|
2
|
+
|
|
3
|
+
Transports have urlopen's signature: transport(Request, timeout=seconds) returns a context
|
|
4
|
+
manager with status, headers and read(). Tests inject one; production uses urllib with
|
|
5
|
+
redirects disabled so a service redirect cannot forward the account key to another host.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import errno
|
|
9
|
+
import json
|
|
10
|
+
import http.client
|
|
11
|
+
import re
|
|
12
|
+
import socket
|
|
13
|
+
import ssl
|
|
14
|
+
import threading
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.parse
|
|
17
|
+
import urllib.request
|
|
18
|
+
from contextlib import contextmanager
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
|
|
21
|
+
from . import bl_info, config, credentials
|
|
22
|
+
|
|
23
|
+
CONTRACT = "0.5"
|
|
24
|
+
MAX_RESPONSE = 4 * 1024 * 1024
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CloudError(RuntimeError):
|
|
28
|
+
"""A contract error, retaining fields callers need for credits, retry and upgrades."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, message, code, status=None, extra=None, error_type=None, detail=None):
|
|
31
|
+
super().__init__(message)
|
|
32
|
+
self.code = code
|
|
33
|
+
self.status = status
|
|
34
|
+
self.extra = dict(extra or {})
|
|
35
|
+
self.error_type = error_type
|
|
36
|
+
self.detail = detail # A transport failure's describe() text; never a URL, header or key.
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class Response:
|
|
41
|
+
data: dict | None
|
|
42
|
+
status: int
|
|
43
|
+
headers: dict
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
47
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def urllib_transport(request, *, timeout):
|
|
52
|
+
return urllib.request.build_opener(_NoRedirect()).open(request, timeout=timeout)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def validate_url(url):
|
|
56
|
+
"""TLS for credentials and blobs; plaintext is only for loopback development."""
|
|
57
|
+
if not isinstance(url, str) or any(ord(c) <= 32 or ord(c) == 127 for c in url):
|
|
58
|
+
raise ValueError("Invalid HTTP URL")
|
|
59
|
+
parts = urllib.parse.urlsplit(url)
|
|
60
|
+
if (parts.scheme not in {"http", "https"} or not parts.hostname or parts.username is not None
|
|
61
|
+
or parts.password is not None or parts.fragment or parts.port == 0
|
|
62
|
+
or parts.scheme == "http" and parts.hostname not in {"localhost", "127.0.0.1", "::1"}):
|
|
63
|
+
raise ValueError("Invalid HTTP URL")
|
|
64
|
+
return parts
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def contract_headers():
|
|
68
|
+
if threading.current_thread() is not threading.main_thread():
|
|
69
|
+
from . import mainthread
|
|
70
|
+
return mainthread.run_on_main(contract_headers).result(timeout=30)
|
|
71
|
+
import bpy
|
|
72
|
+
version = getattr(getattr(bpy, "app", None), "version", ())
|
|
73
|
+
blender = ".".join(map(str, version)) if version else "unknown"
|
|
74
|
+
addon = ".".join(map(str, bl_info["version"]))
|
|
75
|
+
return {"x-loopcut-contract": CONTRACT,
|
|
76
|
+
"x-loopcut-client": f"loopcut-addon/{addon} blender/{blender}"}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def describe(error):
|
|
80
|
+
"""A transport failure as class plus errno or SSL reason, e.g. BrokenPipeError [Errno 32 EPIPE].
|
|
81
|
+
|
|
82
|
+
Never the exception's text: socket and HTTP messages can carry keys or signed URLs.
|
|
83
|
+
"""
|
|
84
|
+
if isinstance(error, urllib.error.URLError) and isinstance(error.reason, BaseException):
|
|
85
|
+
error = error.reason
|
|
86
|
+
name = type(error).__name__
|
|
87
|
+
if isinstance(error, ssl.SSLError):
|
|
88
|
+
reason = getattr(error, "reason", None)
|
|
89
|
+
if not isinstance(reason, str):
|
|
90
|
+
found = re.search(r"\[SSL: ([A-Z0-9_]{1,64})\]", str(error))
|
|
91
|
+
reason = found and found[1]
|
|
92
|
+
return f"{name} {reason}" if isinstance(reason, str) and re.fullmatch(r"[A-Z0-9_]{1,64}", reason) else name
|
|
93
|
+
if isinstance(error, TimeoutError):
|
|
94
|
+
return f"{name} (timed out)"
|
|
95
|
+
if isinstance(error, socket.gaierror):
|
|
96
|
+
return f"{name} (name lookup failed)"
|
|
97
|
+
if isinstance(error, OSError) and isinstance(error.errno, int):
|
|
98
|
+
symbol = errno.errorcode.get(error.errno)
|
|
99
|
+
return f"{name} [Errno {error.errno}{' ' + symbol if symbol else ''}]"
|
|
100
|
+
return name
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _unreachable(error, request):
|
|
104
|
+
cause = describe(error)
|
|
105
|
+
print(f"Loopcut: {request.get_method()} request failed in transport: {cause}")
|
|
106
|
+
return CloudError(f"Could not reach the Loopcut service ({cause})", "connection_failed", detail=cause)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _read(response):
|
|
110
|
+
body = response.read(MAX_RESPONSE + 1)
|
|
111
|
+
if len(body) > MAX_RESPONSE:
|
|
112
|
+
raise CloudError("Loopcut response is too large", "invalid_response", response.status)
|
|
113
|
+
return body
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _redact(value, secrets):
|
|
117
|
+
if isinstance(value, str):
|
|
118
|
+
for secret in secrets:
|
|
119
|
+
value = value.replace(secret, "[redacted]")
|
|
120
|
+
elif isinstance(value, dict):
|
|
121
|
+
value = {_redact(k, secrets): _redact(v, secrets) for k, v in value.items()}
|
|
122
|
+
elif isinstance(value, list):
|
|
123
|
+
value = [_redact(v, secrets) for v in value]
|
|
124
|
+
return value
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _error(status, body, request):
|
|
128
|
+
try:
|
|
129
|
+
payload = json.loads(body)
|
|
130
|
+
except (ValueError, UnicodeError, RecursionError):
|
|
131
|
+
payload = None
|
|
132
|
+
# Error envelopes are untrusted, even when they echo the Authorization header.
|
|
133
|
+
secrets = [value.removeprefix("Bearer ") for name, value in request.header_items()
|
|
134
|
+
if name.lower() == "authorization" and value]
|
|
135
|
+
query = urllib.parse.urlsplit(request.full_url).query
|
|
136
|
+
if query:
|
|
137
|
+
secrets.extend([request.full_url, query, *[v for _, v in urllib.parse.parse_qsl(query) if v]])
|
|
138
|
+
try:
|
|
139
|
+
payload = _redact(payload, sorted(set(secrets), key=len, reverse=True))
|
|
140
|
+
except RecursionError:
|
|
141
|
+
return CloudError(f"HTTP {status}", "http_error", status)
|
|
142
|
+
error = payload.get("error") if isinstance(payload, dict) else None
|
|
143
|
+
if not isinstance(error, dict):
|
|
144
|
+
return CloudError(f"HTTP {status}", "http_error", status)
|
|
145
|
+
message = error.get("message")
|
|
146
|
+
code = error.get("code")
|
|
147
|
+
extra = {k: v for k, v in error.items() if k not in {"message", "code", "type"}}
|
|
148
|
+
return CloudError(message if isinstance(message, str) else f"HTTP {status}",
|
|
149
|
+
code if isinstance(code, str) else "http_error", status, extra, error.get("type"))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def send(request, transport_fn, timeout):
|
|
153
|
+
"""Raw HTTP also serves presigned PUTs; error bodies never include request secrets."""
|
|
154
|
+
try:
|
|
155
|
+
try:
|
|
156
|
+
response = transport_fn(request, timeout=timeout)
|
|
157
|
+
except urllib.error.HTTPError as ex:
|
|
158
|
+
response = ex
|
|
159
|
+
with response:
|
|
160
|
+
body = _read(response)
|
|
161
|
+
status, headers = response.status, dict(response.headers)
|
|
162
|
+
except (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException) as ex:
|
|
163
|
+
# Transport messages and HTTP reason strings can contain keys or signed URLs: describe() only.
|
|
164
|
+
raise _unreachable(ex, request) from None
|
|
165
|
+
if not (200 <= status < 300 or status == 304):
|
|
166
|
+
raise _error(status, body, request) from None
|
|
167
|
+
return body, status, headers
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@contextmanager
|
|
171
|
+
def open_response(request, transport_fn, timeout):
|
|
172
|
+
"""Keep a successful response open for SSE or large signed downloads."""
|
|
173
|
+
try:
|
|
174
|
+
with transport_fn(request, timeout=timeout) as response:
|
|
175
|
+
if not 200 <= response.status < 300:
|
|
176
|
+
raise _error(response.status, _read(response), request) from None
|
|
177
|
+
yield response
|
|
178
|
+
except urllib.error.HTTPError as ex:
|
|
179
|
+
with ex:
|
|
180
|
+
body = _read(ex)
|
|
181
|
+
raise _error(ex.code, body, request) from None
|
|
182
|
+
except (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException) as ex:
|
|
183
|
+
# As in send(): transport messages and reasons can carry keys or signed URLs.
|
|
184
|
+
raise _unreachable(ex, request) from None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class Client:
|
|
188
|
+
def __init__(self, *, base_url=None, api_key=None, transport=None, timeout=30, client_headers=None):
|
|
189
|
+
self.base_url = (base_url if base_url is not None else config.SERVICE_URL).rstrip("/")
|
|
190
|
+
parts = validate_url(self.base_url)
|
|
191
|
+
if parts.query:
|
|
192
|
+
raise ValueError("Service base URL cannot contain a query")
|
|
193
|
+
self.api_key = api_key
|
|
194
|
+
self.transport = transport or urllib_transport
|
|
195
|
+
self.timeout = timeout
|
|
196
|
+
# A main-thread snapshot lets job workers avoid reading Blender or its preferences.
|
|
197
|
+
if client_headers is not None and set(k.lower() for k in client_headers) - {"x-loopcut-contract", "x-loopcut-client"}:
|
|
198
|
+
raise ValueError("Client header snapshots may contain only contract/version headers")
|
|
199
|
+
self.client_headers = dict(client_headers) if client_headers is not None else None
|
|
200
|
+
|
|
201
|
+
def version_headers(self):
|
|
202
|
+
return dict(self.client_headers) if self.client_headers is not None else contract_headers()
|
|
203
|
+
|
|
204
|
+
def _request(self, method, path, payload=None, *, headers=None, auth=True, accept="application/json"):
|
|
205
|
+
parts = urllib.parse.urlsplit(path)
|
|
206
|
+
if (not path.startswith("/") or path.startswith("//") or parts.scheme or parts.netloc
|
|
207
|
+
or parts.fragment or "\\" in path or any(ord(c) < 32 for c in path)):
|
|
208
|
+
raise ValueError("Service requests need a relative endpoint path")
|
|
209
|
+
supplied = dict(headers or {})
|
|
210
|
+
reserved = {"authorization", "x-loopcut-contract", "x-loopcut-client", "content-type"}
|
|
211
|
+
if reserved.intersection(k.lower() for k in supplied):
|
|
212
|
+
raise ValueError("Contract headers cannot be overridden")
|
|
213
|
+
supplied.update(self.version_headers())
|
|
214
|
+
supplied["Accept"] = accept
|
|
215
|
+
if auth:
|
|
216
|
+
key = self.api_key if self.api_key is not None else credentials.api_key(f"{self.base_url}/v1")
|
|
217
|
+
if (not isinstance(key, str) or not key.startswith("lc_") or len(key) <= 3
|
|
218
|
+
or any(c.isspace() or ord(c) < 32 or ord(c) == 127 for c in key)):
|
|
219
|
+
raise CloudError("Sign in to Loopcut first", "sign_in_required")
|
|
220
|
+
supplied["Authorization"] = f"Bearer {key}"
|
|
221
|
+
body = None
|
|
222
|
+
if payload is not None:
|
|
223
|
+
body = json.dumps(payload, ensure_ascii=False, allow_nan=False).encode("utf-8")
|
|
224
|
+
supplied["Content-Type"] = "application/json"
|
|
225
|
+
return urllib.request.Request(self.base_url + path, data=body, headers=supplied, method=method)
|
|
226
|
+
|
|
227
|
+
@contextmanager
|
|
228
|
+
def stream(self, path, *, headers=None, timeout=None):
|
|
229
|
+
"""SSE uses the same endpoint, auth, error and redirect rules as JSON requests."""
|
|
230
|
+
request = self._request("GET", path, headers=headers, accept="text/event-stream")
|
|
231
|
+
with open_response(request, self.transport, self.timeout if timeout is None else timeout) as response:
|
|
232
|
+
yield response
|
|
233
|
+
|
|
234
|
+
@contextmanager
|
|
235
|
+
def download(self, url):
|
|
236
|
+
"""Presigned storage GETs never carry the Loopcut account key."""
|
|
237
|
+
validate_url(url)
|
|
238
|
+
request = urllib.request.Request(url, headers=self.version_headers(), method="GET")
|
|
239
|
+
with open_response(request, self.transport, self.timeout) as response:
|
|
240
|
+
yield response
|
|
241
|
+
|
|
242
|
+
def request_response(self, method, path, payload=None, *, headers=None, auth=True):
|
|
243
|
+
request = self._request(method, path, payload, headers=headers, auth=auth)
|
|
244
|
+
body, status, response_headers = send(request, self.transport, self.timeout)
|
|
245
|
+
if status in {204, 304}:
|
|
246
|
+
return Response(None, status, response_headers)
|
|
247
|
+
try:
|
|
248
|
+
data = json.loads(body)
|
|
249
|
+
except (ValueError, UnicodeError, RecursionError):
|
|
250
|
+
raise CloudError("Invalid JSON from Loopcut", "invalid_response", status) from None
|
|
251
|
+
if not isinstance(data, dict):
|
|
252
|
+
raise CloudError("Expected an object from Loopcut", "invalid_response", status)
|
|
253
|
+
return Response(data, status, response_headers)
|
|
254
|
+
|
|
255
|
+
def request(self, method, path, payload=None, **options):
|
|
256
|
+
return self.request_response(method, path, payload, **options).data
|
loopcut/addon/config.py
ADDED
loopcut/agent_tools.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"about": "How each Loopcut cloud tool appears to the agent: its MCP tool name and a short description. Input schemas come from the server's catalog. A catalog tool missing here keeps its own name and the server's description.",
|
|
3
|
+
"tools": {
|
|
4
|
+
"render": {
|
|
5
|
+
"name": "render_quote",
|
|
6
|
+
"description": "Price a cloud render of a saved .blend on an RTX 4090. Packs the scene and every texture, library and cache it uses with a local headless Blender and uploads what the cloud lacks. While the result says status \"preparing\", call again with the same arguments. Frames default to frame 1 only. Check warnings for missing files (they render pink)."
|
|
7
|
+
},
|
|
8
|
+
"asset_search": {
|
|
9
|
+
"name": "asset_search",
|
|
10
|
+
"description": "Search free CC0 libraries (Poly Haven, ambientCG, Physically Based) for models, materials, HDRIs and textures, with licence, size, preview and download details. Try it before generating a model or texture."
|
|
11
|
+
},
|
|
12
|
+
"image_to_3d": {
|
|
13
|
+
"name": "image_to_3d",
|
|
14
|
+
"description": "Make a textured 3D model (GLB) from a prompt, a local reference image, or both. For new props and concept meshes, not exact CAD. Takes 1-5 minutes."
|
|
15
|
+
},
|
|
16
|
+
"retopology": {
|
|
17
|
+
"name": "remesh",
|
|
18
|
+
"description": "Cloud remesh: rebuild a mesh with clean quads or triangles at a target face count, with its textures baked onto the new mesh. Export the object to a .glb first. Quads come back as FBX, triangles as GLB."
|
|
19
|
+
},
|
|
20
|
+
"sfx": {
|
|
21
|
+
"name": "sound_effect",
|
|
22
|
+
"description": "Make a sound effect (WAV, 0.5-30 s) from a description: impacts, ambience, foley. Not speech or music."
|
|
23
|
+
},
|
|
24
|
+
"voice_line": {
|
|
25
|
+
"name": "voice_line",
|
|
26
|
+
"description": "Speak a line of text as WAV audio with a chosen voice and direction, for characters or narration."
|
|
27
|
+
},
|
|
28
|
+
"image_generate": {
|
|
29
|
+
"name": "image_generate",
|
|
30
|
+
"description": "Make a PNG image from a prompt, optionally guided by a local reference image: concept art, backplates, references."
|
|
31
|
+
},
|
|
32
|
+
"image_edit": {
|
|
33
|
+
"name": "image_edit",
|
|
34
|
+
"description": "Change an existing image (a local PNG, JPEG or WebP) from a text instruction, with an optional PNG mask, e.g. to restyle a render."
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|