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/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 []}
|