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/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