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/mcp_server.py ADDED
@@ -0,0 +1,340 @@
1
+ """A stdio MCP server (JSON-RPC 2.0, one message per line) exposing Service as tools.
2
+
3
+ The tool list follows the server's catalog: one quote tool per open Loopcut tool, whose input
4
+ schema is the catalog's, plus sign-in, account and the shared job tools. When the catalog changes
5
+ (a tool opens, the user signs in, an account setting changes) the server sends
6
+ notifications/tools/list_changed. Only the tools capability is offered, with text results, which
7
+ every MCP client since the 2024-11-05 protocol reads. Each tool call runs on its own thread so a
8
+ long wait never blocks pings or other calls; replies share one locked writer. Anything else that
9
+ prints (the vendored HTTP module logs transport failures) goes to stderr, because stdout carries
10
+ only the protocol.
11
+
12
+ Tool calls return within MCP_WAIT_MAX seconds: Codex gives a tool 60 s by default. Long work
13
+ (packing, uploading, rendering, downloading) continues between calls, and each result says what
14
+ to call next.
15
+ """
16
+
17
+ import copy
18
+ import json
19
+ import os
20
+ import sys
21
+ import threading
22
+ import time
23
+ import urllib.parse
24
+ import urllib.request
25
+ from pathlib import Path
26
+
27
+ from . import __version__
28
+ from . import catalog as catalogs
29
+ from .service import RENDER, LoopcutError, Service
30
+
31
+ PROTOCOL_VERSIONS = ("2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05")
32
+ FALLBACK_PROTOCOL = "2025-06-18"
33
+ MCP_WAIT_MAX = 50
34
+ DEFAULT_WAIT = 40
35
+
36
+ INSTRUCTIONS = """\
37
+ Loopcut cloud tools for Blender work, paid from the user's Loopcut credits (1 credit = $0.01).
38
+ The first call opens a browser to sign in (or returns a verification_url to show the user;
39
+ then call the same tool again). Every paid tool returns a quote first and spends nothing: tell the
40
+ user the price and call job_start only after they agree, or when it is within a budget they gave.
41
+ asset_search is free and runs at once: try it before generating a model or texture. For a long
42
+ render, quote a small test frame first. job_start and job_status wait for the job and download the
43
+ results: renders into loopcut_renders/<job_id>/ next to the .blend, everything else into
44
+ loopcut_outputs/<job_id>/ in the working folder. Import them with bpy (GLB or FBX models, WAV audio,
45
+ PNG images). Save the scene before render_quote; the cloud renders the file on disk."""
46
+
47
+ _WAIT = {"type": "integer", "minimum": 0, "maximum": MCP_WAIT_MAX,
48
+ "description": f"Seconds to wait before returning progress (default {DEFAULT_WAIT})."}
49
+ _JOB = {"type": "string", "description": "Job id from job_start, e.g. job_…"}
50
+ _OUT = {"type": "string", "description": "Folder for the results (default: loopcut_outputs/<job_id>, or loopcut_renders/<job_id> next to the .blend)."}
51
+ _CONFIRM = {"type": "string", "description": "Only if the user approved going past a daily_limit_reached or rate_limited refusal: its confirm_token."}
52
+ _PAID = " Returns a quote and spends nothing; call job_start once the user agrees to the price."
53
+ _FREE = " Free: runs at once and returns the results."
54
+ _BLOCKED = {"private_mode": "Blocked on this account while Private mode is on (its provider may train on inputs). ",
55
+ "plan": "Not included in this account's plan. "}
56
+
57
+
58
+ def _fixed(name, title, description, properties, required=(), **hints):
59
+ # Directory listings need a title and readOnlyHint or destructiveHint on every tool.
60
+ return {"name": name, "title": title, "description": description,
61
+ "inputSchema": {"type": "object", "properties": properties, "required": list(required),
62
+ "additionalProperties": False},
63
+ "annotations": {"title": title, "openWorldHint": True, **hints}}
64
+
65
+
66
+ LOGIN = _fixed("loopcut_login", "Sign in to Loopcut", (
67
+ "Sign in to the user's Loopcut account, or switch accounts. Any tool called while signed out does this by itself. "
68
+ "Opens the sign-in page in the browser and waits; if it returns a verification_url instead, show it to the user "
69
+ "and call again after they approve."),
70
+ {"wait_seconds": _WAIT}, readOnlyHint=False, destructiveHint=False, idempotentHint=True)
71
+ ACCOUNT = _fixed("loopcut_account", "Loopcut account", (
72
+ "The signed-in Loopcut account: plan, credit balance, Private mode and no-generation settings, and which tools "
73
+ "it can use."), {}, readOnlyHint=True)
74
+ JOB_START = _fixed("job_start", "Start a quoted job (spends credits)", (
75
+ "Start a quoted job; this spends the user's Loopcut credits, at most the quote. Only call it after the user agreed "
76
+ "to the quoted price or set a budget that covers it. Calling it again for the same quote returns the same job. "
77
+ "Waits for the job and downloads the results when it finishes in time."),
78
+ {"quote_id": {"type": "string"}, "wait_seconds": _WAIT, "out_dir": _OUT, "confirm_token": _CONFIRM}, ["quote_id"],
79
+ readOnlyHint=False, destructiveHint=True, idempotentHint=True) # Spending credits can't be undone.
80
+ JOB_STATUS = _fixed("job_status", "Job status and results", (
81
+ "Wait for a job, then download its results (hash-checked; finished files are kept). Call it again until the "
82
+ "result has the files or the status is failed or canceled. A first render can spend minutes starting a GPU."),
83
+ {"job_id": _JOB, "wait_seconds": _WAIT, "out_dir": _OUT}, ["job_id"],
84
+ readOnlyHint=True) # Changes nothing on the account; it only saves the job's results locally.
85
+ JOB_CANCEL = _fixed("job_cancel", "Cancel a job", "Cancel a queued or running job. Unspent credits are refunded.",
86
+ {"job_id": _JOB}, ["job_id"], readOnlyHint=False, destructiveHint=True, idempotentHint=True)
87
+
88
+
89
+ def _description(descriptor):
90
+ entry = catalogs.AGENT_TOOLS.get(descriptor["name"])
91
+ if entry:
92
+ text = entry["description"]
93
+ else: # A tool newer than this client: the server's own words, without the add-on's apply step.
94
+ text = ". ".join(sentence for sentence in descriptor.get("description", "").split(". ")
95
+ if "Applying the result" not in sentence).strip().rstrip(".") + "."
96
+ free = (descriptor.get("cost") or {}).get("credits_estimate") == 0
97
+ blocked = _BLOCKED.get(descriptor.get("unavailable_reason")) if descriptor.get("available") is False else None
98
+ if descriptor["name"] == RENDER:
99
+ return (blocked or "") + text + _PAID.replace("job_start", "job_start with the quote_id")
100
+ return (blocked or "") + text + (_FREE if free else _PAID)
101
+
102
+
103
+ def _file_property(spec):
104
+ kinds = " or ".join(catalogs.MIME_NAMES.get(mime, mime) for mime in spec["accept"])
105
+ return {"type": "string", "description": f"Local path to a {kinds} file, at most {spec['max_mb']} MB."}
106
+
107
+
108
+ def _lean(schema, names=False):
109
+ """The schema without regular expressions: the server checks them, and they only cost the agent context.
110
+ names: schema is a properties map, whose keys are input names, not keywords."""
111
+ if isinstance(schema, dict):
112
+ return {key: _lean(value, key == "properties" and not names) for key, value in schema.items()
113
+ if names or key != "pattern"}
114
+ if isinstance(schema, list):
115
+ return [_lean(value) for value in schema]
116
+ return schema
117
+
118
+
119
+ def cloud_tool(descriptor):
120
+ """The MCP tool for one catalog tool: its input schema, its file inputs as local paths, and a confirm token."""
121
+ schema = _lean(copy.deepcopy(descriptor["input_schema"]))
122
+ properties = dict(schema.get("properties") or {})
123
+ required = list(schema.get("required") or [])
124
+ if descriptor["name"] == RENDER:
125
+ properties = {"blend": {"type": "string", "description": "Path to the saved .blend file."}, **properties,
126
+ "blender": {"type": "string", "description": "Blender executable to pack with (default: LOOPCUT_BLENDER, PATH, then the usual install folders)."},
127
+ "wait_seconds": _WAIT}
128
+ required = ["blend", *required]
129
+ for spec in descriptor.get("file_inputs") or []:
130
+ properties[spec["field"]] = _file_property(spec)
131
+ if spec.get("required"):
132
+ required.append(spec["field"])
133
+ free = (descriptor.get("cost") or {}).get("credits_estimate") == 0
134
+ if not free: # Free tools are never held by a spending limit.
135
+ properties["confirm_token"] = _CONFIRM
136
+ display = descriptor.get("display") or {}
137
+ title = display.get("title") or descriptor.get("title") or descriptor["name"]
138
+ # Quoting (and the free asset search) spends nothing and changes nothing on the account.
139
+ return {"name": catalogs.agent_name(descriptor["name"]), "title": title, "description": _description(descriptor),
140
+ "inputSchema": {"type": "object", "properties": properties, "required": list(dict.fromkeys(required)),
141
+ "additionalProperties": False},
142
+ "annotations": {"title": title, "readOnlyHint": True, "openWorldHint": True}}
143
+
144
+
145
+ def tool_list(catalog):
146
+ return [LOGIN, ACCOUNT, *(cloud_tool(descriptor) for descriptor in catalog.visible()), JOB_START, JOB_STATUS, JOB_CANCEL]
147
+
148
+
149
+ def _wait(arguments, cap=MCP_WAIT_MAX):
150
+ value = arguments.get("wait_seconds", DEFAULT_WAIT)
151
+ if type(value) is not int or not 0 <= value <= MCP_WAIT_MAX:
152
+ raise LoopcutError(f"wait_seconds must be an integer from 0 to {MCP_WAIT_MAX}", "invalid_input",
153
+ {"field": "wait_seconds"})
154
+ return max(0, min(value, cap))
155
+
156
+
157
+ def _string(arguments, name):
158
+ value = arguments.get(name)
159
+ if not isinstance(value, str) or not value:
160
+ raise LoopcutError(f"{name} is required", "invalid_input", {"field": name})
161
+ return value
162
+
163
+
164
+ SIGNED_OUT = {"sign_in_required", "invalid_api_key"}
165
+ SIGN_IN_MARGIN = 3 # Seconds kept back from the sign-in wait for the original call's own requests.
166
+
167
+
168
+ def call_tool(service, name, arguments, budget=MCP_WAIT_MAX):
169
+ """Run one tool; returns a JSON-ready result or raises LoopcutError.
170
+
171
+ Signed out (no stored key, or a revoked one, and no LOOPCUT_API_KEY), any tool that needs the
172
+ account starts the browser sign-in, as loopcut_login does, and waits for approval for what is
173
+ left of the time budget. Approved in time, the original call goes ahead; otherwise the result is
174
+ the sign-in link, and the same pending sign-in is picked up when the agent calls again."""
175
+ started = time.monotonic()
176
+ try:
177
+ return _dispatch(service, name, arguments, budget)
178
+ except LoopcutError as ex:
179
+ if ex.code not in SIGNED_OUT or name == "loopcut_login" or not getattr(service, "can_sign_in", lambda: False)():
180
+ raise
181
+ left = lambda: budget - (time.monotonic() - started) # noqa: E731
182
+ signed = service.login(wait=max(0, int(left() - SIGN_IN_MARGIN)))
183
+ if not signed.get("signed_in"):
184
+ return {**signed, "next": (f"Show the user verification_url to sign in to Loopcut (code {signed.get('user_code')}); "
185
+ f"once they approve, call {name} again with the same arguments.")}
186
+ return _dispatch(service, name, arguments, max(0, int(left())))
187
+
188
+
189
+ def _dispatch(service, name, arguments, cap):
190
+ if not isinstance(arguments, dict):
191
+ raise LoopcutError("Tool arguments must be an object", "invalid_input")
192
+ if name == "loopcut_login":
193
+ return service.login(wait=_wait(arguments, cap))
194
+ if name == "loopcut_account":
195
+ return service.account()
196
+ if name == "job_start":
197
+ return service.start(_string(arguments, "quote_id"), confirm_token=arguments.get("confirm_token"),
198
+ wait=_wait(arguments, cap), out_dir=arguments.get("out_dir"))
199
+ if name == "job_status":
200
+ return service.status(_string(arguments, "job_id"), wait=_wait(arguments, cap), download=True,
201
+ out_dir=arguments.get("out_dir"))
202
+ if name == "job_cancel":
203
+ return service.cancel(_string(arguments, "job_id"))
204
+ descriptor = service.catalog().find(name) if isinstance(name, str) else None
205
+ if descriptor is None:
206
+ raise LoopcutError(f"Unknown tool: {name}. The tool list may have changed; list the tools again.", "unknown_tool")
207
+ if descriptor["name"] == RENDER:
208
+ settings = {key: arguments[key] for key in descriptor["input_schema"].get("properties", {}) if key in arguments}
209
+ return service.quote(_string(arguments, "blend"), settings, blender=arguments.get("blender"),
210
+ wait=_wait(arguments, cap), confirm_token=arguments.get("confirm_token"))
211
+ fields = {spec["field"] for spec in descriptor.get("file_inputs") or []}
212
+ files = {key: value for key, value in arguments.items() if key in fields}
213
+ inputs = {key: value for key, value in arguments.items() if key not in fields and key != "confirm_token"}
214
+ return service.quote_tool(descriptor["name"], inputs, files, confirm_token=arguments.get("confirm_token"),
215
+ wait=min(DEFAULT_WAIT, cap))
216
+
217
+
218
+ class Server:
219
+ def __init__(self, service, output):
220
+ self.service = service
221
+ self._output = output
222
+ self._write_lock = threading.Lock()
223
+ self._calls = []
224
+ self._listed = None # The tool list the client last saw.
225
+ self._roots = False # Whether the client offers roots (its workspace folders).
226
+ self._requests = 0
227
+ if hasattr(service, "on_catalog_change"):
228
+ service.on_catalog_change = self._catalog_changed
229
+
230
+ def _send(self, message):
231
+ line = json.dumps(message, ensure_ascii=False, separators=(",", ":")) + "\n"
232
+ with self._write_lock:
233
+ self._output.write(line.encode("utf-8"))
234
+ self._output.flush()
235
+
236
+ def _reply(self, identifier, result=None, error=None):
237
+ message = {"jsonrpc": "2.0", "id": identifier}
238
+ message.update({"error": error} if error else {"result": result})
239
+ self._send(message)
240
+
241
+ def _catalog_changed(self, catalog):
242
+ if self._listed is None:
243
+ return
244
+ tools = tool_list(catalog)
245
+ if tools != self._listed:
246
+ self._listed = tools
247
+ self._send({"jsonrpc": "2.0", "method": "notifications/tools/list_changed"})
248
+
249
+ def _tools(self):
250
+ tools = tool_list(self.service.catalog())
251
+ self._listed = tools
252
+ return tools
253
+
254
+ def _tool_reply(self, identifier, params):
255
+ try:
256
+ result, is_error = call_tool(self.service, params.get("name"), params.get("arguments") or {}), False
257
+ except LoopcutError as ex:
258
+ result, is_error = ex.as_dict(), True
259
+ except Exception as ex: # A bug must not kill the server; the agent sees the failure.
260
+ print(f"loopcut: {params.get('name')} failed: {ex!r}", file=sys.stderr)
261
+ result, is_error = {"error": {"code": "internal_error", "message": f"{type(ex).__name__}: {ex}"}}, True
262
+ self._reply(identifier, {"content": [{"type": "text", "text": json.dumps(result, indent=2, ensure_ascii=False)}],
263
+ "isError": is_error})
264
+
265
+ def _ask_roots(self):
266
+ """Ask the client for its workspace folders; results then default to the first one."""
267
+ self._requests += 1
268
+ self._send({"jsonrpc": "2.0", "id": f"loopcut-roots-{self._requests}", "method": "roots/list"})
269
+
270
+ def _answer(self, message):
271
+ """A response to one of this server's requests: only roots/list is ever sent."""
272
+ roots = (message.get("result") or {}).get("roots") if str(message.get("id", "")).startswith("loopcut-roots-") else None
273
+ if not isinstance(roots, list) or not hasattr(self.service, "roots"):
274
+ return
275
+ folders = []
276
+ for root in roots:
277
+ uri = root.get("uri") if isinstance(root, dict) else None
278
+ if isinstance(uri, str) and uri.startswith("file://"):
279
+ folders.append(Path(urllib.request.url2pathname(urllib.parse.urlsplit(uri).path)))
280
+ self.service.roots = folders
281
+
282
+ def handle(self, message):
283
+ if not isinstance(message, dict) or message.get("jsonrpc") != "2.0" or not isinstance(message.get("method"), str):
284
+ if isinstance(message, dict) and "id" in message and "method" not in message:
285
+ self._answer(message)
286
+ return
287
+ self._reply(message.get("id") if isinstance(message, dict) else None,
288
+ error={"code": -32600, "message": "Invalid request"})
289
+ return
290
+ method, identifier, params = message["method"], message.get("id"), message.get("params") or {}
291
+ if "id" not in message:
292
+ if self._roots and method in {"notifications/initialized", "notifications/roots/list_changed"}:
293
+ self._ask_roots()
294
+ return # Other notifications (cancelled, …) need no reply.
295
+ if method == "initialize":
296
+ self._roots = isinstance((params.get("capabilities") or {}).get("roots"), dict)
297
+ requested = params.get("protocolVersion")
298
+ self._reply(identifier, {
299
+ "protocolVersion": requested if requested in PROTOCOL_VERSIONS else FALLBACK_PROTOCOL,
300
+ "capabilities": {"tools": {"listChanged": True}},
301
+ "serverInfo": {"name": "loopcut", "title": "Loopcut", "version": __version__},
302
+ "instructions": INSTRUCTIONS})
303
+ elif method == "ping":
304
+ self._reply(identifier, {})
305
+ elif method == "tools/list":
306
+ self._reply(identifier, {"tools": self._tools()})
307
+ elif method == "tools/call":
308
+ call = threading.Thread(target=self._tool_reply, args=(identifier, params), daemon=True)
309
+ self._calls = [thread for thread in self._calls if thread.is_alive()] + [call]
310
+ call.start()
311
+ else:
312
+ self._reply(identifier, error={"code": -32601, "message": f"Method not found: {method}"})
313
+
314
+ def serve(self, lines):
315
+ for raw in lines:
316
+ if not raw.strip():
317
+ continue
318
+ try:
319
+ message = json.loads(raw)
320
+ except ValueError:
321
+ self._reply(None, error={"code": -32700, "message": "Parse error"})
322
+ continue
323
+ for item in message if isinstance(message, list) else [message]:
324
+ self.handle(item)
325
+ for call in self._calls: # Input closed: answer the calls already running, then exit.
326
+ call.join()
327
+
328
+
329
+ def main(service=None):
330
+ # Only protocol messages may reach the client's pipe. Keep it on a private descriptor and point
331
+ # fd 1 at stderr, so stray prints and child processes (the browser opener, Blender) cannot write to it.
332
+ sys.stdout.flush()
333
+ output = os.fdopen(os.dup(1), "wb")
334
+ os.dup2(2, 1)
335
+ sys.stdout = sys.stderr
336
+ service = service or Service()
337
+ if hasattr(service, "catalog"): # Read the catalog while the client starts up, before it lists the tools.
338
+ threading.Thread(target=service.catalog, daemon=True).start()
339
+ Server(service, output).serve(sys.stdin.buffer)
340
+ return 0
loopcut/pack.py ADDED
@@ -0,0 +1,154 @@
1
+ """Pack a saved .blend into a render bundle with any stock Blender, then upload it.
2
+
3
+ Collection is the add-on's own: a factory-startup, background Blender child with script
4
+ autoexecution off runs addon/bundles.py as a script, which copies every texture, library and
5
+ cache the scene reads into a staging folder with relative paths and saves scene.blend there.
6
+ The child gets a private config folder and temp folder and none of the parent's environment
7
+ beyond PATH, HOME and locale, so no Loopcut key reaches it.
8
+
9
+ The manifest's source is {"kind": "job"} (contract §3.3): this bundle is not an add-on
10
+ checkpoint. Everything after collection uses only the standard library.
11
+ """
12
+
13
+ import glob
14
+ import hashlib
15
+ import json
16
+ import os
17
+ import shutil
18
+ import subprocess
19
+ import sys
20
+ import tempfile
21
+ from pathlib import Path
22
+
23
+ from .addon import blobs, bundles
24
+
25
+ PACK_TIMEOUT = 900 # Seconds for one Blender collection run, as long as a heavy scene's save.
26
+ KEEP_ENV = ("PATH", "HOME", "LANG", "LC_ALL", "LC_CTYPE", "SYSTEMROOT", "USERPROFILE", "APPDATA", "LOCALAPPDATA")
27
+ LOG_TAIL = 4000
28
+
29
+
30
+ class PackError(RuntimeError):
31
+ pass
32
+
33
+
34
+ def _candidates():
35
+ yield os.environ.get("LOOPCUT_BLENDER")
36
+ yield os.environ.get("BLENDER")
37
+ yield shutil.which("blender")
38
+ if sys.platform == "darwin":
39
+ for root in ("/Applications", os.path.expanduser("~/Applications")):
40
+ yield f"{root}/Blender.app/Contents/MacOS/Blender"
41
+ yield from sorted(glob.glob(f"{root}/Blender*.app/Contents/MacOS/Blender"), reverse=True)
42
+ yield "/Applications/Loopcut.app/Contents/MacOS/Loopcut"
43
+ elif sys.platform == "win32":
44
+ for root in filter(None, (os.environ.get("ProgramFiles"), os.environ.get("ProgramFiles(x86)"))):
45
+ yield from sorted(glob.glob(os.path.join(root, "Blender Foundation", "Blender*", "blender.exe")), reverse=True)
46
+ else:
47
+ yield "/snap/bin/blender"
48
+
49
+
50
+ def find_blender(explicit=None):
51
+ """The Blender binary to pack with: the argument, LOOPCUT_BLENDER, BLENDER, PATH, then the
52
+ usual install folders."""
53
+ if explicit:
54
+ path = Path(explicit).expanduser()
55
+ if path.is_dir() and path.suffix == ".app":
56
+ path = next(iter(sorted((path / "Contents" / "MacOS").glob("*"))), path)
57
+ if not path.is_file() or not os.access(path, os.X_OK):
58
+ raise PackError(f"Blender not found at {explicit}")
59
+ return str(path)
60
+ for candidate in _candidates():
61
+ if candidate and Path(candidate).is_file() and os.access(candidate, os.X_OK):
62
+ return candidate
63
+ raise PackError("Blender not found. Install Blender, or set LOOPCUT_BLENDER to its executable.")
64
+
65
+
66
+ def _tail(log):
67
+ with log.open("rb") as handle:
68
+ handle.seek(max(0, handle.seek(0, os.SEEK_END) - LOG_TAIL))
69
+ return handle.read(LOG_TAIL).decode("utf-8", errors="replace")
70
+
71
+
72
+ def collect(blend, staging, blender):
73
+ """Run the add-on's collector on a saved .blend into an empty staging folder.
74
+
75
+ Returns {blender, scene, created_at, missing, unsupported, system} (bundles._collect_opened).
76
+ """
77
+ blend = Path(blend).expanduser().resolve()
78
+ if not blend.is_file() or blend.suffix.lower() != ".blend":
79
+ raise PackError(f"Not a saved .blend file: {blend}")
80
+ with tempfile.TemporaryDirectory(prefix="loopcut-pack-") as temporary:
81
+ control = Path(temporary)
82
+ for name in ("tmp", "config", "scripts"):
83
+ (control / name).mkdir()
84
+ env = {name: os.environ[name] for name in KEEP_ENV if os.environ.get(name)}
85
+ env.update(TMPDIR=str(control / "tmp"), TEMP=str(control / "tmp"), TMP=str(control / "tmp"),
86
+ BLENDER_USER_CONFIG=str(control / "config"), BLENDER_USER_SCRIPTS=str(control / "scripts"))
87
+ request, reply, log = control / "request.json", control / "reply.json", control / "blender.log"
88
+ request.write_text(json.dumps({"blend": str(blend), "staging": str(staging), "original": None}),
89
+ encoding="utf-8")
90
+ command = [blender, "-b", "--factory-startup", "--disable-autoexec", "--python-exit-code", "1",
91
+ "--python", str(Path(bundles.__file__).resolve()), "--", str(request), str(reply)]
92
+ try:
93
+ with log.open("wb") as output:
94
+ completed = subprocess.run(command, stdout=output, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
95
+ env=env, timeout=PACK_TIMEOUT)
96
+ except subprocess.TimeoutExpired as ex:
97
+ raise PackError(f"Blender took longer than {PACK_TIMEOUT} s to collect the scene") from ex
98
+ except OSError as ex:
99
+ raise PackError(f"Could not run Blender at {blender}: {ex.strerror or ex}") from ex
100
+ if completed.returncode or not reply.is_file():
101
+ raise PackError("Blender could not collect the scene:\n" + _tail(log))
102
+ result = json.loads(reply.read_text(encoding="utf-8"))
103
+ if not isinstance(result, dict) or not (Path(staging) / bundles.ENTRY).is_file():
104
+ raise PackError("Blender returned an invalid bundle result")
105
+ return result
106
+
107
+
108
+ def manifest(staging, info):
109
+ """The contract bundle manifest for a collected staging folder (SPEC §3.3)."""
110
+ root = bundles._root(staging)
111
+ files = [{"path": name, "sha256": blobs.sha256(path, max_bytes=blobs.MAX_BLOB_SIZE), "size": path.stat().st_size}
112
+ for name, path in bundles._staged_files(root)]
113
+ return {"schema": bundles.SCHEMA, "blender": info["blender"], "entry": bundles.ENTRY,
114
+ "files": sorted(files, key=lambda item: item["path"]),
115
+ "scene": {key: info["scene"][key] for key in ("name", "camera", "frame_start", "frame_end", "fps")},
116
+ "source": {"kind": "job"},
117
+ "created_at": info["created_at"]}
118
+
119
+
120
+ def canonical_json(value):
121
+ """The bytes the server hashes: sorted keys, no spaces, whole floats written as integers."""
122
+ return json.dumps(bundles._js_numbers(value), sort_keys=True, separators=(",", ":"), ensure_ascii=False,
123
+ allow_nan=False).encode("utf-8")
124
+
125
+
126
+ def upload(staging, value, *, client, multipart=None, progress=None):
127
+ """Upload the members the server lacks, then the manifest; return the manifest's SHA-256."""
128
+ root = bundles._root(staging)
129
+ files = [{**item, "path": root / item["path"]} for item in value["files"]]
130
+ blobs.upload_missing(files, client=client, progress=progress, multipart=multipart)
131
+ body = canonical_json(value)
132
+ digest = hashlib.sha256(body).hexdigest()
133
+ with tempfile.TemporaryDirectory(prefix="loopcut-manifest-") as temporary:
134
+ path = Path(temporary) / "manifest.json"
135
+ path.write_bytes(body)
136
+ blobs.upload_missing([{"path": path, "sha256": digest, "size": len(body), "mime": "application/json"}],
137
+ client=client, progress=progress)
138
+ return digest
139
+
140
+
141
+ def pack_and_upload(blend, *, client, blender=None, multipart=None, progress=None):
142
+ """Collect, hash and upload a saved .blend. Returns the bundle hash and what was collected."""
143
+ binary = find_blender(blender)
144
+ say = progress or (lambda message: None)
145
+ with tempfile.TemporaryDirectory(prefix="loopcut-bundle-") as staging:
146
+ say("Collecting the scene and its files with Blender…")
147
+ info = collect(blend, staging, binary)
148
+ value = manifest(staging, info)
149
+ total = sum(item["size"] for item in value["files"])
150
+ say(f"Uploading {len(value['files'])} files ({total / 1e6:.1f} MB) that the cloud does not have yet…")
151
+ digest = upload(staging, value, client=client, multipart=multipart, progress=say)
152
+ return {"bundle": digest, "blender": info["blender"], "blender_path": binary, "scene": value["scene"],
153
+ "files": len(value["files"]), "bytes": total,
154
+ "missing": info.get("missing") or [], "unsupported": info.get("unsupported") or []}