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
loopcut/service.py
ADDED
|
@@ -0,0 +1,839 @@
|
|
|
1
|
+
"""Loopcut's cloud tools as plain calls: sign in, quote, start, wait, download and cancel.
|
|
2
|
+
|
|
3
|
+
The CLI and the MCP server are thin layers over Service. Every call returns a JSON-ready dict
|
|
4
|
+
or raises LoopcutError with a message written for the person or agent reading it.
|
|
5
|
+
|
|
6
|
+
Every cloud tool goes through the same contract API (§3.4), paid from the signed-in account's
|
|
7
|
+
credits: a quote prices the exact request, and the job spends at most what the quote said.
|
|
8
|
+
Render first packs the saved .blend into a bundle; the other tools upload their file inputs,
|
|
9
|
+
given as local paths, by content hash. A quote lasts ten minutes; start() re-quotes an expired
|
|
10
|
+
one and goes ahead only if the price is unchanged. A 0-credit quote (asset search) may run
|
|
11
|
+
straight through. Results download, hash-checked and resumable, into loopcut_renders/<job_id>/
|
|
12
|
+
next to the .blend for a render and loopcut_outputs/<job_id>/ in the working folder otherwise.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import mimetypes
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
import uuid
|
|
23
|
+
import webbrowser
|
|
24
|
+
from datetime import datetime, timezone
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from . import __version__, pack, state
|
|
28
|
+
from . import catalog as catalogs
|
|
29
|
+
from .addon import blobs, cloud_http
|
|
30
|
+
|
|
31
|
+
DEFAULT_SERVICE_URL = "https://loopcut.org"
|
|
32
|
+
HEADERS = {"x-loopcut-contract": "0.6", "x-loopcut-client": f"loopcut/{__version__}"}
|
|
33
|
+
RENDER = "render"
|
|
34
|
+
TERMINAL = {"succeeded", "failed", "canceled"}
|
|
35
|
+
POLL_SECONDS = {RENDER: 5} # Other tools are polled every 2 s: most finish in seconds.
|
|
36
|
+
QUOTE_MARGIN = 30 # Seconds: a quote this close to expiry is renewed before starting.
|
|
37
|
+
CATALOG_MAX_AGE = 600 # Seconds before the server's catalog is read again, as the add-on does.
|
|
38
|
+
CATALOG_RETRY = 60 # Seconds before retrying a server whose catalog could not be read.
|
|
39
|
+
CATALOG_TIMEOUT = 10
|
|
40
|
+
DOWNLOAD_CHUNK = 1024 * 1024
|
|
41
|
+
LISTED = 12
|
|
42
|
+
_SAFE_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}\Z")
|
|
43
|
+
_EXTENSIONS = {"image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp", "application/json": ".json",
|
|
44
|
+
"model/gltf-binary": ".glb", "model/vnd.fbx": ".fbx", "audio/wav": ".wav", "audio/x-wav": ".wav",
|
|
45
|
+
"audio/mpeg": ".mp3", "video/mp4": ".mp4"}
|
|
46
|
+
SIGN_IN_MESSAGE = "Not signed in to Loopcut, or the key was revoked. Call loopcut_login (CLI: loopcut login)."
|
|
47
|
+
_KEEP = {"confirm_url", "balance", "needed", "upgrade", "confirm_token", "retry_after", "field", "daily_limit",
|
|
48
|
+
"spent_today", "resets_at", "plan", "data_class", "scope", "refunded", "min_client"}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class LoopcutError(RuntimeError):
|
|
52
|
+
def __init__(self, message, code="error", extra=None):
|
|
53
|
+
super().__init__(message)
|
|
54
|
+
self.code = code
|
|
55
|
+
self.extra = dict(extra or {})
|
|
56
|
+
|
|
57
|
+
def as_dict(self):
|
|
58
|
+
return {"error": {"code": self.code, "message": str(self), **self.extra}}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _from_cloud(error, tool=None):
|
|
62
|
+
"""A contract error as a LoopcutError whose message says what to do next."""
|
|
63
|
+
extra = {k: v for k, v in error.extra.items() if k in _KEEP}
|
|
64
|
+
code, message = error.code, str(error)
|
|
65
|
+
name = catalogs.agent_name(tool) if tool else "this tool"
|
|
66
|
+
if code in {"sign_in_required", "invalid_api_key"}:
|
|
67
|
+
message = SIGN_IN_MESSAGE
|
|
68
|
+
elif code == "age_confirmation_required":
|
|
69
|
+
message = ("Paid Loopcut tools need a one-time confirmation that the user is 18 or older. "
|
|
70
|
+
+ (f"Ask them to confirm at {extra['confirm_url']}, then try again." if extra.get("confirm_url")
|
|
71
|
+
else "They confirm once on their Loopcut account page, then try again."))
|
|
72
|
+
elif code == "insufficient_credits":
|
|
73
|
+
message = (f"Not enough Loopcut credits: this needs {extra.get('needed', 'more')}, and the balance is "
|
|
74
|
+
f"{extra.get('balance', 'lower')}. The user can add credits or upgrade at "
|
|
75
|
+
f"{extra.get('upgrade') or 'their Loopcut account page'}.")
|
|
76
|
+
elif code == "private_mode":
|
|
77
|
+
data_class = f" (data class {extra['data_class']})" if extra.get("data_class") else ""
|
|
78
|
+
message = (f"Private mode is on for this Loopcut account, and it blocks {name}: its provider may train on "
|
|
79
|
+
f"what it receives{data_class}. Nothing was charged. The user can turn Private mode off in the "
|
|
80
|
+
"Loopcut app's account settings or with `loopcut settings --private-mode off`, or do without "
|
|
81
|
+
"this tool.")
|
|
82
|
+
elif code == "generation_disabled":
|
|
83
|
+
message = (f"Generation is off for this Loopcut account (the no-generation setting), so generative tools "
|
|
84
|
+
f"such as {name} are refused. Nothing was charged. The user can turn generation back on in the "
|
|
85
|
+
"Loopcut app or with `loopcut settings --no-generation off`.")
|
|
86
|
+
elif code == "tool_disabled":
|
|
87
|
+
message = f"{name} is switched off on Loopcut right now. Nothing was charged; try again later."
|
|
88
|
+
elif code == "concurrency_limit":
|
|
89
|
+
message = "Too many Loopcut jobs are running at once. Wait for one to finish (job_status), then try again."
|
|
90
|
+
elif code == "rate_limited" and not extra.get("confirm_token"):
|
|
91
|
+
message += f" Wait {extra.get('retry_after', 60)} seconds, then try again."
|
|
92
|
+
elif code == "vendor_failed":
|
|
93
|
+
message += " Unspent credits were refunded; a retry is a new quote."
|
|
94
|
+
elif code == "contract_unsupported":
|
|
95
|
+
message = "This loopcut client is too old for the server. Update it: uvx --refresh loopcut, or pip install -U loopcut."
|
|
96
|
+
elif code == "invalid_input" and extra.get("field") == "input.topology":
|
|
97
|
+
# Servers before the connector counted as FBX-capable refuse quad remesh results for it.
|
|
98
|
+
message = 'This Loopcut server can\'t send quad results to this client yet. Use topology: "triangles" for a GLB.'
|
|
99
|
+
elif code == "invalid_input" and extra.get("field"):
|
|
100
|
+
message = f"{message} (field: {extra['field']})"
|
|
101
|
+
if extra.get("confirm_token"):
|
|
102
|
+
message += (" If the user approves going past this limit, repeat the same call with"
|
|
103
|
+
f" confirm_token={extra['confirm_token']} (valid 10 minutes, one job).")
|
|
104
|
+
return LoopcutError(message, code, extra)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _now():
|
|
108
|
+
return datetime.now(timezone.utc)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _expired(expires_at, margin=0):
|
|
112
|
+
try:
|
|
113
|
+
moment = datetime.fromisoformat(expires_at.replace("Z", "+00:00"))
|
|
114
|
+
except (AttributeError, ValueError):
|
|
115
|
+
return True
|
|
116
|
+
return (moment - _now()).total_seconds() <= margin
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _spent_or_expired(quote):
|
|
120
|
+
"""Whether a finished quote task must not be handed out again: it expired, or started a job (asking
|
|
121
|
+
again for the same thing is a new take, so it gets a new quote)."""
|
|
122
|
+
if _expired(quote["expires_at"], QUOTE_MARGIN):
|
|
123
|
+
return True
|
|
124
|
+
try:
|
|
125
|
+
return bool((state.load("quotes", quote["quote_id"]) or {}).get("job_id"))
|
|
126
|
+
except (OSError, ValueError):
|
|
127
|
+
return True
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _remaining(deadline):
|
|
131
|
+
return None if deadline is None else max(0.0, deadline - time.monotonic())
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def frame_count(settings):
|
|
135
|
+
start = settings.get("frame_start", 1)
|
|
136
|
+
end = settings.get("frame_end", start + 119 if "turntable" in settings else start)
|
|
137
|
+
return max(0, (end - start) // settings.get("frame_step", 1) + 1)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _warnings(packed):
|
|
141
|
+
lines = [f"Missing {item.get('type', 'file')}: {item.get('path') or item.get('name')}" for item in packed["missing"]]
|
|
142
|
+
lines += [f"Not bundled ({item.get('reason', 'unsupported')}): {item.get('path') or item.get('name')}"
|
|
143
|
+
for item in packed["unsupported"]]
|
|
144
|
+
return lines
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
_SNIFFED = {"image/png", "image/jpeg", "image/webp", "audio/wav", "audio/x-wav", "model/gltf-binary", "model/vnd.fbx"}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _sniff(path):
|
|
151
|
+
"""The MIME type of a local file from its first bytes, else from its extension."""
|
|
152
|
+
with open(path, "rb") as handle:
|
|
153
|
+
head = handle.read(32)
|
|
154
|
+
if head.startswith(b"\x89PNG\r\n\x1a\n"):
|
|
155
|
+
return "image/png"
|
|
156
|
+
if head.startswith(b"\xff\xd8\xff"):
|
|
157
|
+
return "image/jpeg"
|
|
158
|
+
if head[:4] == b"RIFF" and head[8:12] == b"WEBP":
|
|
159
|
+
return "image/webp"
|
|
160
|
+
if head[:4] == b"RIFF" and head[8:12] == b"WAVE":
|
|
161
|
+
return "audio/wav"
|
|
162
|
+
if head.startswith(b"glTF"):
|
|
163
|
+
return "model/gltf-binary"
|
|
164
|
+
if head.startswith(b"Kaydara FBX Binary"):
|
|
165
|
+
return "model/vnd.fbx"
|
|
166
|
+
guess = mimetypes.guess_type(Path(path).name)[0]
|
|
167
|
+
if guess in _SNIFFED:
|
|
168
|
+
return "application/octet-stream" # Named like one of the types above, but its bytes are not.
|
|
169
|
+
return guess or "application/octet-stream"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _local_files(descriptor, files):
|
|
173
|
+
"""Check the local file inputs against the descriptor: [{field, path, size, mtime_ns, mime}]."""
|
|
174
|
+
specs = {spec["field"]: spec for spec in descriptor.get("file_inputs") or []}
|
|
175
|
+
found = []
|
|
176
|
+
for field, value in (files or {}).items():
|
|
177
|
+
if value in (None, ""):
|
|
178
|
+
continue
|
|
179
|
+
spec = specs.get(field)
|
|
180
|
+
if spec is None or not isinstance(value, str):
|
|
181
|
+
raise LoopcutError(f"{field} is not a file input of {catalogs.agent_name(descriptor['name'])}.",
|
|
182
|
+
"invalid_input", {"field": field})
|
|
183
|
+
path = Path(value).expanduser().resolve()
|
|
184
|
+
kinds = " or ".join(catalogs.MIME_NAMES.get(mime, mime) for mime in spec["accept"])
|
|
185
|
+
try:
|
|
186
|
+
stat = path.stat()
|
|
187
|
+
except OSError:
|
|
188
|
+
raise LoopcutError(f"No file at {path}. {field} takes a local path to a {kinds} file.", "invalid_input",
|
|
189
|
+
{"field": field}) from None
|
|
190
|
+
if not path.is_file():
|
|
191
|
+
raise LoopcutError(f"{path} is not a file.", "invalid_input", {"field": field})
|
|
192
|
+
if stat.st_size > spec["max_mb"] * 1024 * 1024:
|
|
193
|
+
raise LoopcutError(f"{path.name} is {stat.st_size / 1e6:.1f} MB; {field} takes at most {spec['max_mb']} MB.",
|
|
194
|
+
"invalid_input", {"field": field})
|
|
195
|
+
mime = _sniff(path)
|
|
196
|
+
if mime not in spec["accept"]:
|
|
197
|
+
raise LoopcutError(f"{path.name} is not a {kinds} file, which {field} needs.", "invalid_input", {"field": field})
|
|
198
|
+
found.append({"field": field, "path": str(path), "size": stat.st_size, "mtime_ns": stat.st_mtime_ns, "mime": mime})
|
|
199
|
+
given = {item["field"] for item in found}
|
|
200
|
+
for field, spec in specs.items():
|
|
201
|
+
if spec.get("required") and field not in given:
|
|
202
|
+
kinds = " or ".join(catalogs.MIME_NAMES.get(mime, mime) for mime in spec["accept"])
|
|
203
|
+
raise LoopcutError(f"{field} is required: a local path to a {kinds} file.", "invalid_input", {"field": field})
|
|
204
|
+
return found
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class _Task:
|
|
208
|
+
"""Background work (packing, uploading, downloading) that a tool call can return before, and the
|
|
209
|
+
next call with the same arguments picks up where it is."""
|
|
210
|
+
|
|
211
|
+
def __init__(self, run):
|
|
212
|
+
self.stage, self.message = "starting", "Starting"
|
|
213
|
+
self.result = self.error = None
|
|
214
|
+
self.count = 0
|
|
215
|
+
self.started = time.monotonic()
|
|
216
|
+
self.done = threading.Event()
|
|
217
|
+
self._thread = threading.Thread(target=self._run, args=(run,), daemon=True)
|
|
218
|
+
self._thread.start()
|
|
219
|
+
|
|
220
|
+
def say(self, message):
|
|
221
|
+
self.message = message
|
|
222
|
+
|
|
223
|
+
def _run(self, run):
|
|
224
|
+
try:
|
|
225
|
+
self.result = run(self)
|
|
226
|
+
except Exception as ex: # Reported to the caller on its next poll, then retried fresh.
|
|
227
|
+
self.error = ex
|
|
228
|
+
finally:
|
|
229
|
+
self.done.set()
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class Service:
|
|
233
|
+
def __init__(self, base_url=None, api_key=None):
|
|
234
|
+
self.base_url = (base_url or os.environ.get("LOOPCUT_SERVICE_URL") or DEFAULT_SERVICE_URL).rstrip("/")
|
|
235
|
+
self._key = api_key or os.environ.get("LOOPCUT_API_KEY") or None
|
|
236
|
+
self._login = None
|
|
237
|
+
self._tasks = {}
|
|
238
|
+
self._lock = threading.Lock()
|
|
239
|
+
self._catalog = None
|
|
240
|
+
self._catalog_lock = threading.Lock()
|
|
241
|
+
self.on_catalog_change = None # Called with each new Catalog (the MCP server announces tool changes).
|
|
242
|
+
self.roots = [] # The client's workspace folders (MCP roots), when it shares them.
|
|
243
|
+
|
|
244
|
+
# ------------------------------------------------------------------ HTTP
|
|
245
|
+
|
|
246
|
+
def api_key(self):
|
|
247
|
+
if self._key:
|
|
248
|
+
return self._key
|
|
249
|
+
stored = state.credentials(self.base_url)
|
|
250
|
+
return stored["api_key"] if stored else None
|
|
251
|
+
|
|
252
|
+
def work_dir(self):
|
|
253
|
+
"""The folder results go under by default: the client's first workspace root, else the working
|
|
254
|
+
folder, unless that is the plugin's own install folder (Codex starts plugin servers there),
|
|
255
|
+
where results would be lost on the next update: then the home folder."""
|
|
256
|
+
for root in self.roots:
|
|
257
|
+
if root.is_dir():
|
|
258
|
+
return root
|
|
259
|
+
cwd = Path.cwd().resolve()
|
|
260
|
+
for name in ("PLUGIN_ROOT", "CLAUDE_PLUGIN_ROOT"):
|
|
261
|
+
plugin = os.environ.get(name)
|
|
262
|
+
if plugin:
|
|
263
|
+
plugin = Path(plugin).expanduser().resolve()
|
|
264
|
+
if cwd == plugin or plugin in cwd.parents:
|
|
265
|
+
return Path.home()
|
|
266
|
+
return cwd
|
|
267
|
+
|
|
268
|
+
def can_sign_in(self):
|
|
269
|
+
"""Whether a signed-out call may start the browser sign-in: not when LOOPCUT_API_KEY was given."""
|
|
270
|
+
return not self._key
|
|
271
|
+
|
|
272
|
+
def _require_key(self):
|
|
273
|
+
"""Fail before any slow work (packing, uploading) when there is no key at all."""
|
|
274
|
+
if not self.api_key():
|
|
275
|
+
raise LoopcutError(SIGN_IN_MESSAGE, "sign_in_required")
|
|
276
|
+
|
|
277
|
+
def _client(self, timeout=60):
|
|
278
|
+
return cloud_http.Client(base_url=self.base_url, api_key=self.api_key() or "", client_headers=HEADERS,
|
|
279
|
+
timeout=timeout)
|
|
280
|
+
|
|
281
|
+
def _call(self, method, path, payload=None, *, auth=True, headers=None, tool=None, timeout=60):
|
|
282
|
+
try:
|
|
283
|
+
return self._client(timeout).request(method, path, payload, auth=auth, headers=headers)
|
|
284
|
+
except cloud_http.CloudError as ex:
|
|
285
|
+
raise _from_cloud(ex, tool) from None
|
|
286
|
+
|
|
287
|
+
def _task(self, key, run, stale=None):
|
|
288
|
+
"""The running or finished task for key, starting a new one if there is none or the last failed."""
|
|
289
|
+
with self._lock:
|
|
290
|
+
task = self._tasks.get(key)
|
|
291
|
+
if task is None or task.done.is_set() and (task.error or (stale and stale(task.result))):
|
|
292
|
+
task = self._tasks[key] = _Task(run)
|
|
293
|
+
return task
|
|
294
|
+
|
|
295
|
+
# ------------------------------------------------------------------ catalog
|
|
296
|
+
|
|
297
|
+
def _manifest(self, timeout=60):
|
|
298
|
+
"""The manifest as this account sees it; signed out (or with a revoked key) the public one."""
|
|
299
|
+
if self.api_key():
|
|
300
|
+
try:
|
|
301
|
+
return self._call("GET", "/v1/manifest", timeout=timeout)
|
|
302
|
+
except LoopcutError as ex:
|
|
303
|
+
if ex.code not in {"invalid_api_key", "sign_in_required"}:
|
|
304
|
+
raise
|
|
305
|
+
return self._call("GET", "/v1/manifest", auth=False, timeout=timeout)
|
|
306
|
+
|
|
307
|
+
def _cache_id(self):
|
|
308
|
+
return hashlib.sha256(self.base_url.encode("utf-8")).hexdigest()[:32]
|
|
309
|
+
|
|
310
|
+
def _use_catalog(self, fresh, manifest=None):
|
|
311
|
+
if manifest is not None:
|
|
312
|
+
try:
|
|
313
|
+
state.save("catalogs", self._cache_id(), {
|
|
314
|
+
"base_url": self.base_url, "saved_at": _now().isoformat(timespec="seconds"),
|
|
315
|
+
"manifest": {key: manifest.get(key) for key in ("tools", "capabilities", "flags")}})
|
|
316
|
+
except OSError:
|
|
317
|
+
pass # The cache is only a fallback.
|
|
318
|
+
self._catalog = fresh
|
|
319
|
+
if self.on_catalog_change:
|
|
320
|
+
self.on_catalog_change(fresh)
|
|
321
|
+
return fresh
|
|
322
|
+
|
|
323
|
+
def catalog(self, refresh=False):
|
|
324
|
+
"""The tool catalog: the server's (read again after 10 minutes), else the last one read from
|
|
325
|
+
this server, else the snapshot shipped with the package."""
|
|
326
|
+
with self._catalog_lock:
|
|
327
|
+
current = self._catalog
|
|
328
|
+
if current and not refresh:
|
|
329
|
+
limit = CATALOG_MAX_AGE if current.source == "server" else CATALOG_RETRY
|
|
330
|
+
if time.monotonic() - current.read_at < limit:
|
|
331
|
+
return current
|
|
332
|
+
try:
|
|
333
|
+
manifest = self._manifest(timeout=CATALOG_TIMEOUT)
|
|
334
|
+
if not isinstance(manifest.get("tools"), list):
|
|
335
|
+
raise ValueError("manifest without tools")
|
|
336
|
+
except (LoopcutError, OSError, ValueError):
|
|
337
|
+
if current:
|
|
338
|
+
current.read_at = time.monotonic() # Keep it; try the server again later.
|
|
339
|
+
return current
|
|
340
|
+
cached = None
|
|
341
|
+
try:
|
|
342
|
+
cached = state.load("catalogs", self._cache_id())
|
|
343
|
+
except (OSError, ValueError):
|
|
344
|
+
pass
|
|
345
|
+
if cached and cached.get("base_url") == self.base_url and isinstance(cached.get("manifest"), dict):
|
|
346
|
+
return self._use_catalog(catalogs.Catalog.from_manifest(cached["manifest"], "cache"))
|
|
347
|
+
return self._use_catalog(catalogs.Catalog.from_snapshot())
|
|
348
|
+
return self._use_catalog(catalogs.Catalog.from_manifest(manifest), manifest)
|
|
349
|
+
|
|
350
|
+
def _descriptor(self, name):
|
|
351
|
+
found = self.catalog().find(name)
|
|
352
|
+
if found:
|
|
353
|
+
return found
|
|
354
|
+
known = self.catalog()
|
|
355
|
+
for tool in known.tools.values():
|
|
356
|
+
if name in {tool["name"], catalogs.agent_name(tool["name"])}:
|
|
357
|
+
if known.settings().get("no_generation") and tool.get("kind") == "gen":
|
|
358
|
+
raise _from_cloud(cloud_http.CloudError("", "generation_disabled"), tool["name"])
|
|
359
|
+
raise LoopcutError(f"{name} is not open on Loopcut yet.", "tool_disabled")
|
|
360
|
+
names = ", ".join(catalogs.agent_name(tool["name"]) for tool in known.visible())
|
|
361
|
+
raise LoopcutError(f"Unknown Loopcut tool {name!r}. Open tools: {names}.", "unknown_tool")
|
|
362
|
+
|
|
363
|
+
# ------------------------------------------------------------------ account
|
|
364
|
+
|
|
365
|
+
def login(self, wait=0, open_browser=True):
|
|
366
|
+
"""Sign in with the device link the add-on uses. Returns the link while it is pending."""
|
|
367
|
+
if self.api_key():
|
|
368
|
+
try:
|
|
369
|
+
me = self._call("GET", "/v1/me")
|
|
370
|
+
return {"signed_in": True, "email": me.get("email"), "plan": me.get("plan")}
|
|
371
|
+
except LoopcutError as ex:
|
|
372
|
+
if ex.code != "invalid_api_key" or self._key:
|
|
373
|
+
raise
|
|
374
|
+
state.forget_credentials(self.base_url)
|
|
375
|
+
if self._login is None or _expired(self._login["expires_at"]):
|
|
376
|
+
started = self._call("POST", "/api/device/start", {"device_name": "Loopcut connector"}, auth=False)
|
|
377
|
+
self._login = started
|
|
378
|
+
opened = False
|
|
379
|
+
if open_browser:
|
|
380
|
+
try:
|
|
381
|
+
opened = webbrowser.open(started["verification_url"])
|
|
382
|
+
except webbrowser.Error:
|
|
383
|
+
pass # The link is in the result for the user to open by hand.
|
|
384
|
+
if not opened:
|
|
385
|
+
wait = 0 # Nobody sees a link until this call returns it.
|
|
386
|
+
pending = self._login
|
|
387
|
+
deadline = time.monotonic() + max(0, wait)
|
|
388
|
+
while True:
|
|
389
|
+
polled = self._call("POST", "/api/device/poll", {"device_code": pending["device_code"]}, auth=False)
|
|
390
|
+
status = polled.get("status")
|
|
391
|
+
if status == "approved" and isinstance(polled.get("api_key"), str):
|
|
392
|
+
state.save_credentials(self.base_url, polled["api_key"], polled.get("email"))
|
|
393
|
+
self._login = None
|
|
394
|
+
self.catalog(refresh=True) # The account's own view: settings and availability.
|
|
395
|
+
return {"signed_in": True, "email": polled.get("email")}
|
|
396
|
+
if status in {"denied", "expired"}:
|
|
397
|
+
self._login = None
|
|
398
|
+
raise LoopcutError(f"Sign-in was {status}. Call loopcut_login again for a new link.", f"login_{status}")
|
|
399
|
+
if time.monotonic() >= deadline:
|
|
400
|
+
return {"signed_in": False, "verification_url": pending["verification_url"],
|
|
401
|
+
"user_code": pending["user_code"], "expires_at": pending["expires_at"],
|
|
402
|
+
"next": ("Ask the user to open verification_url, sign in to Loopcut and approve code "
|
|
403
|
+
f"{pending['user_code']}; then call loopcut_login again to finish.")}
|
|
404
|
+
time.sleep(max(0.5, min(int(pending.get("interval") or 5), deadline - time.monotonic())))
|
|
405
|
+
|
|
406
|
+
def logout(self):
|
|
407
|
+
state.forget_credentials(self.base_url)
|
|
408
|
+
return {"signed_in": False}
|
|
409
|
+
|
|
410
|
+
def account(self):
|
|
411
|
+
self._require_key()
|
|
412
|
+
me = self._call("GET", "/v1/me")
|
|
413
|
+
manifest = self._manifest()
|
|
414
|
+
known = self._use_catalog(catalogs.Catalog.from_manifest(manifest), manifest)
|
|
415
|
+
account = manifest.get("account") or {}
|
|
416
|
+
credits, settings = account.get("credits") or {}, account.get("settings") or {}
|
|
417
|
+
tools = [{"tool": catalogs.agent_name(tool["name"]), "available": tool.get("available"),
|
|
418
|
+
"unavailable_reason": tool.get("unavailable_reason")}
|
|
419
|
+
for tool in sorted(known.tools.values(), key=lambda tool: tool["name"])
|
|
420
|
+
if tool.get("status") in catalogs.OPEN]
|
|
421
|
+
notes = []
|
|
422
|
+
blocked = [row["tool"] for row in tools if row["unavailable_reason"] == "private_mode"]
|
|
423
|
+
if settings.get("private_mode"):
|
|
424
|
+
notes.append("Private mode is on: tools whose provider may train on what it receives are refused"
|
|
425
|
+
+ (f" ({', '.join(blocked)})" if blocked else "")
|
|
426
|
+
+ ". Turn it off in the Loopcut app or with `loopcut settings --private-mode off`.")
|
|
427
|
+
if settings.get("no_generation"):
|
|
428
|
+
notes.append("Generation is off: generative tools are hidden and refused. Turn it back on in the Loopcut "
|
|
429
|
+
"app or with `loopcut settings --no-generation off`.")
|
|
430
|
+
return {"email": me.get("email"), "plan": me.get("plan") or account.get("plan"),
|
|
431
|
+
"credits": credits.get("balance"), "credits_monthly": credits.get("monthly"),
|
|
432
|
+
"credits_reset_at": credits.get("resets_at"),
|
|
433
|
+
"settings": {"private_mode": settings.get("private_mode"), "no_generation": settings.get("no_generation")},
|
|
434
|
+
"tools": tools, "notes": notes, "price": "1 credit = $0.01. Every paid tool quotes its price first."}
|
|
435
|
+
|
|
436
|
+
def settings(self, *, private_mode=None, no_generation=None):
|
|
437
|
+
"""Change the account's Private mode or no-generation setting (the user's call, from the CLI)."""
|
|
438
|
+
patch = {key: value for key, value in (("private_mode", private_mode), ("no_generation", no_generation))
|
|
439
|
+
if value is not None}
|
|
440
|
+
self._require_key()
|
|
441
|
+
if not patch:
|
|
442
|
+
return self.account()["settings"]
|
|
443
|
+
result = self._call("PATCH", "/v1/settings", patch)
|
|
444
|
+
self.catalog(refresh=True)
|
|
445
|
+
return result
|
|
446
|
+
|
|
447
|
+
# ------------------------------------------------------------------ quotes
|
|
448
|
+
|
|
449
|
+
def quote(self, blend, settings, *, blender=None, wait=None, confirm_token=None):
|
|
450
|
+
"""Pack, upload and price a render. wait=None blocks; otherwise returns progress after
|
|
451
|
+
`wait` seconds and the same call later picks the work up where it is."""
|
|
452
|
+
settings = {key: value for key, value in (settings or {}).items() if value is not None}
|
|
453
|
+
self._require_key()
|
|
454
|
+
path = Path(blend).expanduser().resolve()
|
|
455
|
+
try:
|
|
456
|
+
stat = path.stat()
|
|
457
|
+
except OSError:
|
|
458
|
+
raise LoopcutError(f"No .blend file at {path}. Save the scene first; the cloud renders the file on disk.",
|
|
459
|
+
"invalid_input", {"field": "blend"}) from None
|
|
460
|
+
key = json.dumps(["render", str(path), stat.st_size, stat.st_mtime_ns, blender or "", settings], sort_keys=True)
|
|
461
|
+
task = self._task(key, lambda job: self._prepare_render(job, path, settings, blender, confirm_token),
|
|
462
|
+
stale=_spent_or_expired)
|
|
463
|
+
if not task.done.wait(wait):
|
|
464
|
+
return {"status": "preparing", "stage": task.stage, "message": task.message,
|
|
465
|
+
"elapsed_s": round(time.monotonic() - task.started),
|
|
466
|
+
"next": "Packing and uploading is still running; call render_quote again with the same arguments."}
|
|
467
|
+
if task.error:
|
|
468
|
+
raise task.error
|
|
469
|
+
return task.result
|
|
470
|
+
|
|
471
|
+
def _prepare_render(self, task, path, settings, blender, confirm_token):
|
|
472
|
+
task.stage = "checking"
|
|
473
|
+
descriptor = self._descriptor(RENDER)
|
|
474
|
+
multipart = blobs.multipart_capability(self.catalog().capabilities.get("blob_multipart"))
|
|
475
|
+
task.stage = "packing"
|
|
476
|
+
try:
|
|
477
|
+
packed = pack.pack_and_upload(path, client=self._client(timeout=120), blender=blender, multipart=multipart,
|
|
478
|
+
progress=task.say)
|
|
479
|
+
except cloud_http.CloudError as ex:
|
|
480
|
+
raise _from_cloud(ex, RENDER) from None
|
|
481
|
+
except (pack.PackError, ValueError) as ex:
|
|
482
|
+
raise LoopcutError(str(ex), "pack_failed") from None
|
|
483
|
+
task.stage = "quoting"
|
|
484
|
+
project = {"id": "blend-" + hashlib.sha256(str(path).encode("utf-8")).hexdigest()[:24], "no_generation": False}
|
|
485
|
+
record = {"body": {"tool": RENDER, "input": settings, "bundle": packed["bundle"]}, "tool_version": descriptor["version"],
|
|
486
|
+
"blend": str(path), "packed": packed, "project": project}
|
|
487
|
+
return self._new_quote(record, confirm_token)
|
|
488
|
+
|
|
489
|
+
def quote_tool(self, name, inputs=None, files=None, *, confirm_token=None, wait=None, out_dir=None):
|
|
490
|
+
"""Upload a tool's file inputs and price the request. A 0-credit quote runs straight through:
|
|
491
|
+
the result is then the finished job with its outputs, if it ends within `wait` seconds."""
|
|
492
|
+
deadline = None if wait is None else time.monotonic() + max(0, wait)
|
|
493
|
+
descriptor = self._descriptor(name)
|
|
494
|
+
if descriptor["name"] == RENDER:
|
|
495
|
+
raise LoopcutError("Render needs a .blend: use render_quote (CLI: loopcut render).", "invalid_input")
|
|
496
|
+
inputs = {key: value for key, value in (inputs or {}).items() if value is not None}
|
|
497
|
+
local = _local_files(descriptor, files)
|
|
498
|
+
self._require_key()
|
|
499
|
+
key = json.dumps(["tool", descriptor["name"], inputs, local], sort_keys=True)
|
|
500
|
+
task = self._task(key, lambda job: self._prepare_tool(job, descriptor, inputs, local, confirm_token),
|
|
501
|
+
stale=_spent_or_expired)
|
|
502
|
+
if not task.done.wait(_remaining(deadline)):
|
|
503
|
+
return {"status": "preparing", "stage": task.stage, "message": task.message,
|
|
504
|
+
"elapsed_s": round(time.monotonic() - task.started),
|
|
505
|
+
"next": f"Uploading is still running; call {catalogs.agent_name(descriptor['name'])} again with the same arguments."}
|
|
506
|
+
if task.error:
|
|
507
|
+
raise task.error
|
|
508
|
+
quote = task.result
|
|
509
|
+
if quote["credits"] != 0:
|
|
510
|
+
return quote
|
|
511
|
+
return self.start(quote["quote_id"], confirm_token=confirm_token, wait=_remaining(deadline), out_dir=out_dir)
|
|
512
|
+
|
|
513
|
+
def _prepare_tool(self, task, descriptor, inputs, local, confirm_token):
|
|
514
|
+
files = {}
|
|
515
|
+
if local:
|
|
516
|
+
task.stage = "uploading"
|
|
517
|
+
multipart = blobs.multipart_capability(self.catalog().capabilities.get("blob_multipart"))
|
|
518
|
+
items = []
|
|
519
|
+
for item in local:
|
|
520
|
+
task.say(f"Hashing {Path(item['path']).name}")
|
|
521
|
+
digest = blobs.sha256(item["path"], max_bytes=blobs.MAX_BLOB_SIZE)
|
|
522
|
+
files[item["field"]] = digest
|
|
523
|
+
items.append({"path": item["path"], "sha256": digest, "size": item["size"], "mime": item["mime"]})
|
|
524
|
+
try:
|
|
525
|
+
blobs.upload_missing(items, client=self._client(timeout=120), multipart=multipart, progress=task.say)
|
|
526
|
+
except cloud_http.CloudError as ex:
|
|
527
|
+
raise _from_cloud(ex, descriptor["name"]) from None
|
|
528
|
+
except ValueError as ex:
|
|
529
|
+
raise LoopcutError(str(ex), "invalid_input") from None
|
|
530
|
+
task.stage = "quoting"
|
|
531
|
+
body = {"tool": descriptor["name"], "input": inputs, **({"files": files} if files else {})}
|
|
532
|
+
project = {"id": "dir-" + hashlib.sha256(str(self.work_dir()).encode("utf-8")).hexdigest()[:24], "no_generation": False}
|
|
533
|
+
record = {"body": body, "tool_version": descriptor["version"], "project": project,
|
|
534
|
+
"local_files": {item["field"]: item["path"] for item in local}}
|
|
535
|
+
return self._new_quote(record, confirm_token)
|
|
536
|
+
|
|
537
|
+
def _new_quote(self, record, confirm_token=None):
|
|
538
|
+
headers = {"x-loopcut-confirm": confirm_token} if confirm_token else None
|
|
539
|
+
quote = self._call("POST", "/v1/quotes", record["body"], headers=headers, tool=record["body"]["tool"])
|
|
540
|
+
record = {**record, "quote_id": quote["quote_id"], "credits": quote["credits"], "expires_at": quote["expires_at"],
|
|
541
|
+
"idempotency_key": uuid.uuid4().hex, "created_at": _now().isoformat(timespec="seconds")}
|
|
542
|
+
if confirm_token:
|
|
543
|
+
record["confirm_token"] = confirm_token # The user's approval covers this request's job too.
|
|
544
|
+
state.save("quotes", record["quote_id"], record)
|
|
545
|
+
return self._quote_result(record)
|
|
546
|
+
|
|
547
|
+
@staticmethod
|
|
548
|
+
def _quote_result(record):
|
|
549
|
+
body = record["body"]
|
|
550
|
+
result = {"status": "quoted", "tool": catalogs.agent_name(body["tool"]), "quote_id": record["quote_id"],
|
|
551
|
+
"credits": record["credits"], "usd": round(record["credits"] / 100, 2), "expires_at": record["expires_at"]}
|
|
552
|
+
if body["tool"] == RENDER:
|
|
553
|
+
packed, settings = record["packed"], body["input"]
|
|
554
|
+
result.update({"frames": frame_count(settings), "settings": settings, "scene": packed["scene"],
|
|
555
|
+
"bundle": {"files": packed["files"], "mb": round(packed["bytes"] / 1e6, 1),
|
|
556
|
+
"packed_with_blender": packed["blender"]},
|
|
557
|
+
"warnings": _warnings(packed)})
|
|
558
|
+
result["next"] = ("Tell the user the price; once they agree, call job_start with this quote_id. The quote "
|
|
559
|
+
"lasts 10 minutes and is the most the job can cost: it charges actual GPU time.")
|
|
560
|
+
else:
|
|
561
|
+
result["input"] = body["input"]
|
|
562
|
+
result["next"] = ("Tell the user the price; once they agree (or it is within a budget they gave), call "
|
|
563
|
+
"job_start with this quote_id. The quote lasts 10 minutes and is the most the job can cost.")
|
|
564
|
+
return result
|
|
565
|
+
|
|
566
|
+
# ------------------------------------------------------------------ jobs
|
|
567
|
+
|
|
568
|
+
def _quote_record(self, quote_id):
|
|
569
|
+
record = state.load("quotes", quote_id)
|
|
570
|
+
for _ in range(5): # An expired quote renewed at the same price points at its renewal.
|
|
571
|
+
if record is None or not record.get("renewed_as") or record.get("job_id"):
|
|
572
|
+
break
|
|
573
|
+
record = state.load("quotes", record["renewed_as"]) or record
|
|
574
|
+
if record is None:
|
|
575
|
+
raise LoopcutError(f"Unknown quote {quote_id}. Get a quote first (render_quote or the tool's own call).",
|
|
576
|
+
"not_found")
|
|
577
|
+
return record
|
|
578
|
+
|
|
579
|
+
def start(self, quote_id, *, confirm_token=None, wait=0, out_dir=None, download=True):
|
|
580
|
+
"""Start a quoted job, which spends credits. Repeating it returns the same job. With `wait`
|
|
581
|
+
(None: until it ends), it also waits and, if download, fetches the outputs."""
|
|
582
|
+
deadline = None if wait is None else time.monotonic() + max(0, wait)
|
|
583
|
+
self._require_key()
|
|
584
|
+
record = self._quote_record(quote_id)
|
|
585
|
+
if record.get("job_id"):
|
|
586
|
+
job = self._call("GET", f"/v1/jobs/{_job_id(record['job_id'])}")
|
|
587
|
+
return self._follow(job, deadline, download=download, out_dir=out_dir)
|
|
588
|
+
original = record
|
|
589
|
+
token = confirm_token or record.get("confirm_token")
|
|
590
|
+
headers = {"x-loopcut-confirm": token} if token else None
|
|
591
|
+
tool = record["body"]["tool"]
|
|
592
|
+
for attempt in (1, 2): # The second only after the server called the quote expired.
|
|
593
|
+
if attempt == 2 or _expired(record["expires_at"], QUOTE_MARGIN):
|
|
594
|
+
renewed = self._new_quote({key: record[key] for key in record if key in {
|
|
595
|
+
"body", "tool_version", "blend", "packed", "project", "local_files"}}, token)
|
|
596
|
+
if renewed["credits"] != record["credits"]:
|
|
597
|
+
return {**renewed, "status": "requoted",
|
|
598
|
+
"next": "The quote expired and the price changed. Confirm the new price, then call job_start with the new quote_id."}
|
|
599
|
+
state.save("quotes", record["quote_id"], {**record, "renewed_as": renewed["quote_id"]})
|
|
600
|
+
record = state.load("quotes", renewed["quote_id"])
|
|
601
|
+
body = {"tool": tool, "tool_version": record.get("tool_version", 1), "input": record["body"]["input"],
|
|
602
|
+
"files": record["body"].get("files") or {}, "quote_id": record["quote_id"],
|
|
603
|
+
"project": record.get("project") or {"id": "dir-default", "no_generation": False},
|
|
604
|
+
"idempotency_key": record["idempotency_key"]}
|
|
605
|
+
if record["body"].get("bundle"):
|
|
606
|
+
body["bundle"] = record["body"]["bundle"]
|
|
607
|
+
try:
|
|
608
|
+
job = self._call("POST", "/v1/jobs", body, headers=headers, tool=tool)
|
|
609
|
+
break
|
|
610
|
+
except LoopcutError as ex:
|
|
611
|
+
if ex.code != "quote_expired" or attempt == 2:
|
|
612
|
+
raise
|
|
613
|
+
if out_dir:
|
|
614
|
+
target = Path(out_dir).expanduser().resolve()
|
|
615
|
+
elif tool == RENDER and record.get("blend"):
|
|
616
|
+
target = Path(record["blend"]).parent / "loopcut_renders" / job["id"]
|
|
617
|
+
else:
|
|
618
|
+
target = self.work_dir() / ("loopcut_renders" if tool == RENDER else "loopcut_outputs") / job["id"]
|
|
619
|
+
state.save("jobs", job["id"], {"job_id": job["id"], "quote_id": record["quote_id"], "tool": tool,
|
|
620
|
+
"blend": record.get("blend"), "out_dir": str(target),
|
|
621
|
+
"credits_quoted": record["credits"], "created_at": _now().isoformat(timespec="seconds")})
|
|
622
|
+
for done in dict.fromkeys((original["quote_id"], record["quote_id"])):
|
|
623
|
+
state.save("quotes", done, {**(state.load("quotes", done) or record), "job_id": job["id"]})
|
|
624
|
+
return self._follow(job, deadline, download=download, out_dir=None)
|
|
625
|
+
|
|
626
|
+
def status(self, job_id, wait=0, *, download=False, out_dir=None):
|
|
627
|
+
"""A job's status, waiting up to `wait` seconds for it to end. With download, a finished
|
|
628
|
+
job's outputs are fetched in the same call (and the next one, if time runs out)."""
|
|
629
|
+
deadline = None if wait is None else time.monotonic() + max(0, wait)
|
|
630
|
+
job = self._call("GET", f"/v1/jobs/{_job_id(job_id)}")
|
|
631
|
+
return self._follow(job, deadline, download=download, out_dir=out_dir)
|
|
632
|
+
|
|
633
|
+
def _follow(self, job, deadline, *, download, out_dir):
|
|
634
|
+
poll = POLL_SECONDS.get(job.get("tool"), 2)
|
|
635
|
+
while job.get("status") not in TERMINAL:
|
|
636
|
+
remaining = _remaining(deadline)
|
|
637
|
+
if remaining is not None and remaining <= 0:
|
|
638
|
+
break
|
|
639
|
+
time.sleep(poll if remaining is None else min(poll, remaining))
|
|
640
|
+
job = self._call("GET", f"/v1/jobs/{_job_id(job.get('id'))}")
|
|
641
|
+
result = self._job_result(job)
|
|
642
|
+
if download and job.get("status") == "succeeded":
|
|
643
|
+
result.update(self.fetch(job["id"], out_dir, wait=_remaining(deadline), job=job))
|
|
644
|
+
return result
|
|
645
|
+
|
|
646
|
+
def cancel(self, job_id):
|
|
647
|
+
return self._job_result(self._call("POST", f"/v1/jobs/{_job_id(job_id)}/cancel"))
|
|
648
|
+
|
|
649
|
+
@staticmethod
|
|
650
|
+
def _job_result(job):
|
|
651
|
+
status, tool = job.get("status"), job.get("tool")
|
|
652
|
+
result = {"job_id": job.get("id"), "tool": catalogs.agent_name(tool) if tool else None, "status": status,
|
|
653
|
+
"progress": job.get("progress"), "credits": job.get("credits")}
|
|
654
|
+
if job.get("error"):
|
|
655
|
+
result["error"] = {"code": job["error"].get("code"), "message": job["error"].get("message")}
|
|
656
|
+
result["next"] = {
|
|
657
|
+
"succeeded": "Call job_status to download the results.",
|
|
658
|
+
"failed": "Unspent credits were refunded. Fix the cause, then quote again: a retry is a new quote and job.",
|
|
659
|
+
"canceled": "Unspent credits were refunded.",
|
|
660
|
+
}.get(status, "Still running; call job_status with wait_seconds to wait for it."
|
|
661
|
+
+ (" A first render can spend several minutes starting a GPU worker." if tool == RENDER else ""))
|
|
662
|
+
return result
|
|
663
|
+
|
|
664
|
+
def fetch(self, job_id, out_dir=None, wait=None, *, job=None):
|
|
665
|
+
"""Download a finished job's outputs. Files already present with the right hash are kept, so
|
|
666
|
+
a call that runs out of time carries on in the background and the next call picks it up."""
|
|
667
|
+
if job is None or job.get("status") != "succeeded":
|
|
668
|
+
job = self._call("GET", f"/v1/jobs/{_job_id(job_id)}")
|
|
669
|
+
if job.get("status") != "succeeded":
|
|
670
|
+
return {**self._job_result(job), "downloaded": False}
|
|
671
|
+
record = state.load("jobs", job["id"]) or {}
|
|
672
|
+
if out_dir:
|
|
673
|
+
out = Path(out_dir).expanduser().resolve()
|
|
674
|
+
elif record.get("out_dir"):
|
|
675
|
+
out = Path(record["out_dir"])
|
|
676
|
+
else:
|
|
677
|
+
out = self.work_dir() / ("loopcut_renders" if job.get("tool") == RENDER else "loopcut_outputs") / job["id"]
|
|
678
|
+
key = json.dumps(["fetch", job["id"], str(out)])
|
|
679
|
+
task = self._task(key, lambda work: self._download_all(work, job, out))
|
|
680
|
+
if not task.done.wait(wait):
|
|
681
|
+
return {"job_id": job["id"], "downloading": True, "out_dir": str(out), "files_saved": task.count,
|
|
682
|
+
"files_total": len(job.get("outputs") or []), "message": task.message,
|
|
683
|
+
"next": "Still downloading; call job_status again (finished files are kept)."}
|
|
684
|
+
with self._lock:
|
|
685
|
+
if self._tasks.get(key) is task:
|
|
686
|
+
del self._tasks[key] # The next fetch checks the files on disk again.
|
|
687
|
+
if task.error:
|
|
688
|
+
raise task.error
|
|
689
|
+
return task.result
|
|
690
|
+
|
|
691
|
+
def _download_all(self, task, job, out):
|
|
692
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
693
|
+
client = self._client(timeout=120)
|
|
694
|
+
outputs = job.get("outputs") or []
|
|
695
|
+
saved = []
|
|
696
|
+
for index, output in enumerate(outputs):
|
|
697
|
+
target = out / _output_name(output, index)
|
|
698
|
+
if not _matches(target, output):
|
|
699
|
+
task.say(f"Downloading {target.name} ({index + 1} of {len(outputs)})")
|
|
700
|
+
try:
|
|
701
|
+
_download(client, output, target)
|
|
702
|
+
except cloud_http.CloudError as ex:
|
|
703
|
+
raise _from_cloud(ex, job.get("tool")) from None
|
|
704
|
+
saved.append((output, target))
|
|
705
|
+
task.count = len(saved)
|
|
706
|
+
return _outputs_result(job, out, saved)
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def _job_id(value):
|
|
710
|
+
if not isinstance(value, str) or not re.fullmatch(r"job_[A-Za-z0-9_-]{1,120}", value):
|
|
711
|
+
raise LoopcutError(f"Invalid job id: {value!r}", "invalid_input", {"field": "job_id"})
|
|
712
|
+
return value
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def _output_name(output, index):
|
|
716
|
+
"""Output names are server data: keep a plain file name, adding the MIME type's extension."""
|
|
717
|
+
name, extension = output.get("name"), _EXTENSIONS.get(output.get("mime"), ".bin")
|
|
718
|
+
if not (isinstance(name, str) and _SAFE_NAME.fullmatch(name) and ".." not in name):
|
|
719
|
+
name = f"output_{index:04d}"
|
|
720
|
+
return name if Path(name).suffix else name + extension
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def _matches(target, output):
|
|
724
|
+
try:
|
|
725
|
+
return target.stat().st_size == output.get("size") and blobs.sha256(target) == output.get("sha256")
|
|
726
|
+
except OSError:
|
|
727
|
+
return False
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def _download(client, output, target):
|
|
731
|
+
"""Stream a signed output URL to a temporary file, verify size and hash, then move it in place."""
|
|
732
|
+
temporary = target.with_name(f".{target.name}.part")
|
|
733
|
+
digest, size = hashlib.sha256(), 0
|
|
734
|
+
try:
|
|
735
|
+
with client.download(output["url"]) as response, temporary.open("wb") as handle:
|
|
736
|
+
for chunk in iter(lambda: response.read(DOWNLOAD_CHUNK), b""):
|
|
737
|
+
size += len(chunk)
|
|
738
|
+
if size > output["size"]:
|
|
739
|
+
break
|
|
740
|
+
digest.update(chunk)
|
|
741
|
+
handle.write(chunk)
|
|
742
|
+
if size != output["size"] or digest.hexdigest() != output["sha256"]:
|
|
743
|
+
raise LoopcutError(f"Downloaded {target.name} does not match its hash; call job_status again.",
|
|
744
|
+
"download_mismatch")
|
|
745
|
+
os.replace(temporary, target)
|
|
746
|
+
finally:
|
|
747
|
+
temporary.unlink(missing_ok=True)
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def _outputs_result(job, out, saved):
|
|
751
|
+
tool = job.get("tool")
|
|
752
|
+
result = {"job_id": job["id"], "out_dir": str(out), "files_saved": len(saved)}
|
|
753
|
+
report = next((target for output, target in saved if output.get("type") == "report"), None)
|
|
754
|
+
if tool == RENDER:
|
|
755
|
+
frames = [str(target) for output, target in saved if output.get("type") == "render_frames"]
|
|
756
|
+
result["frames"] = frames[:LISTED]
|
|
757
|
+
if len(frames) > LISTED:
|
|
758
|
+
result["frames_total"] = len(frames)
|
|
759
|
+
if report:
|
|
760
|
+
result["qa"] = _qa_summary(report)
|
|
761
|
+
result["next"] = "Done. Look at the frames (and the QA report's flagged frames) before telling the user it is finished."
|
|
762
|
+
return result
|
|
763
|
+
if tool == "asset_search" and report:
|
|
764
|
+
result.update(_asset_summary(report))
|
|
765
|
+
return result
|
|
766
|
+
result["files"] = [{"path": str(target), "type": output.get("type"), "mime": output.get("mime"),
|
|
767
|
+
**({"meta": output["meta"]} if output.get("meta") else {})} for output, target in saved][:LISTED]
|
|
768
|
+
result["next"] = _import_hint(output.get("mime") for output, _ in saved)
|
|
769
|
+
return result
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def _import_hint(mimes):
|
|
773
|
+
hints = {
|
|
774
|
+
"model/gltf-binary": "import the GLB with bpy.ops.import_scene.gltf(filepath=path)",
|
|
775
|
+
"model/vnd.fbx": "import the FBX with bpy.ops.import_scene.fbx(filepath=path)",
|
|
776
|
+
"audio/wav": "add the WAV to the sequencer as a sound strip (scene.sequence_editor_create(), then strips.new_sound)",
|
|
777
|
+
"audio/mpeg": "add the MP3 to the sequencer as a sound strip",
|
|
778
|
+
"image/png": "load the PNG with bpy.data.images.load(path) as a texture, reference or backplate",
|
|
779
|
+
"application/json": "read the JSON report",
|
|
780
|
+
}
|
|
781
|
+
found = [hints[mime] for mime in dict.fromkeys(mimes) if mime in hints]
|
|
782
|
+
return ("Done. To use it in Blender, " + "; ".join(found) + ".") if found else "Done."
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def _qa_summary(path):
|
|
786
|
+
try:
|
|
787
|
+
report = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
788
|
+
except (OSError, ValueError):
|
|
789
|
+
return {"summary": "The QA report could not be read."}
|
|
790
|
+
flagged = report.get("flagged") if isinstance(report.get("flagged"), list) else []
|
|
791
|
+
flags = {row.get("frame"): row.get("flags") for row in report.get("frames") or []
|
|
792
|
+
if isinstance(row, dict) and row.get("flags")}
|
|
793
|
+
return {"summary": report.get("summary"), "flagged_frames": flagged[:LISTED],
|
|
794
|
+
"flags": {str(frame): flags.get(frame) for frame in flagged[:LISTED]}, "report": str(path)}
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def _asset_summary(path):
|
|
798
|
+
"""The asset search report, trimmed to what an agent needs to pick and fetch an asset."""
|
|
799
|
+
try:
|
|
800
|
+
report = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
801
|
+
except (OSError, ValueError):
|
|
802
|
+
return {"next": "The search report could not be read."}
|
|
803
|
+
results = []
|
|
804
|
+
for row in report.get("results") or []:
|
|
805
|
+
if not isinstance(row, dict):
|
|
806
|
+
continue
|
|
807
|
+
item = {key: row.get(key) for key in ("id", "title", "kind") if row.get(key) is not None}
|
|
808
|
+
item["license"] = (row.get("license") or {}).get("id")
|
|
809
|
+
if (row.get("attribution") or {}).get("required"):
|
|
810
|
+
item["attribution"] = row["attribution"].get("text")
|
|
811
|
+
for key in ("polycount", "dimensions_m", "max_resolution", "page_url", "preview_url"):
|
|
812
|
+
if row.get(key) is not None:
|
|
813
|
+
item[key] = row[key]
|
|
814
|
+
if row.get("downloads"):
|
|
815
|
+
item["downloads"] = [{key: entry.get(key) for key in ("label", "format", "size", "url")}
|
|
816
|
+
for entry in row["downloads"][:4] if isinstance(entry, dict)]
|
|
817
|
+
if row.get("source") == "polyhaven" and row.get("source_id"):
|
|
818
|
+
item["files_api"] = f"https://api.polyhaven.com/files/{row['source_id']}"
|
|
819
|
+
library = row.get("remote_library")
|
|
820
|
+
if isinstance(library, dict) and library.get("entries"):
|
|
821
|
+
item["blender_library"] = {"url": library.get("url"), "entries": [
|
|
822
|
+
{"name": entry.get("name"), "id_type": entry.get("id_type"), "file": entry.get("file"),
|
|
823
|
+
"urls": [f.get("url") for f in (entry.get("files") or [])[:3] if isinstance(f, dict)]}
|
|
824
|
+
for entry in library["entries"][:3] if isinstance(entry, dict)]}
|
|
825
|
+
results.append(item)
|
|
826
|
+
credits = [source.get("credit") for source in report.get("sources") or []
|
|
827
|
+
if isinstance(source, dict) and source.get("credit_required") and source.get("credit")]
|
|
828
|
+
summary = {"query": report.get("query"), "mode": report.get("mode"), "count": len(results), "results": results,
|
|
829
|
+
"report": str(path)}
|
|
830
|
+
if credits:
|
|
831
|
+
summary["show_credit"] = credits
|
|
832
|
+
if report.get("notices"):
|
|
833
|
+
summary["notices"] = report["notices"]
|
|
834
|
+
summary["next"] = ("Show the user the matches (and show_credit next to them). To use one, download a file from "
|
|
835
|
+
"downloads, the Poly Haven files_api or blender_library, then import it with bpy: "
|
|
836
|
+
"bpy.ops.import_scene.gltf for glTF/GLB, bpy.data.libraries.load for .blend, "
|
|
837
|
+
"bpy.data.images.load for textures and HDRIs.") if results else (
|
|
838
|
+
"No matches. Try other words or kinds, or generate the asset instead.")
|
|
839
|
+
return summary
|