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/catalog.py ADDED
@@ -0,0 +1,90 @@
1
+ """The Loopcut tool catalog: which cloud tools are open, their input schemas and file inputs.
2
+
3
+ The server's GET /v1/manifest is the source (it answers signed out too), so a tool that opens
4
+ on the server shows up here without a client release. When it can't be read, the last catalog
5
+ read from the same server (kept in the state folder) stands in, then the snapshot shipped with
6
+ the package (catalog.json, refreshed by scripts/snapshot_catalog.py before each release).
7
+ """
8
+
9
+ import json
10
+ import re
11
+ import time
12
+ from pathlib import Path
13
+
14
+ OPEN = {"live", "beta"}
15
+ TOOL_NAME = re.compile(r"[A-Za-z0-9_-]{1,64}")
16
+ _HERE = Path(__file__).parent
17
+ AGENT_TOOLS = json.loads((_HERE / "agent_tools.json").read_text(encoding="utf-8"))["tools"]
18
+ # Availability reasons that mean the account can't use the tool at all; the tool is hidden.
19
+ _HIDDEN_REASONS = {"disabled", "region"}
20
+ MIME_NAMES = {"image/png": "PNG", "image/jpeg": "JPEG", "image/webp": "WebP", "model/gltf-binary": "GLB",
21
+ "model/vnd.fbx": "FBX", "audio/wav": "WAV", "audio/mpeg": "MP3", "video/mp4": "MP4"}
22
+
23
+
24
+ def snapshot():
25
+ """The catalog shipped with this release: {source, taken_at, capabilities, tools}."""
26
+ return json.loads((_HERE / "catalog.json").read_text(encoding="utf-8"))
27
+
28
+
29
+ def agent_name(tool_name):
30
+ entry = AGENT_TOOLS.get(tool_name)
31
+ return entry["name"] if entry else tool_name
32
+
33
+
34
+ def _order(descriptor):
35
+ names = list(AGENT_TOOLS)
36
+ name = descriptor["name"]
37
+ return (names.index(name) if name in names else len(names), name)
38
+
39
+
40
+ def usable(descriptor, account=None):
41
+ """Whether the agent sees this tool: open on the server, buildable by this client, and allowed
42
+ by the account (no-generation hides generative tools, as the contract asks of every client)."""
43
+ if not isinstance(descriptor.get("input_schema"), dict) or descriptor.get("status") not in OPEN:
44
+ return False
45
+ if not TOOL_NAME.fullmatch(agent_name(descriptor["name"])):
46
+ return False # MCP clients and directories take names of up to 64 letters, digits, _ and -.
47
+ if descriptor.get("available") is False and descriptor.get("unavailable_reason") in _HIDDEN_REASONS:
48
+ return False
49
+ bundle = descriptor.get("bundle_input")
50
+ if bundle and bundle.get("required") and descriptor["name"] != "render":
51
+ return False # Only render's scene bundle can be built here.
52
+ settings = (account or {}).get("settings") or {}
53
+ return not (settings.get("no_generation") and descriptor.get("kind") == "gen")
54
+
55
+
56
+ class Catalog:
57
+ """One reading of the catalog. source is "server", "cache" or "snapshot"."""
58
+
59
+ def __init__(self, tools, source, *, account=None, capabilities=None, flags=None):
60
+ self.tools = {tool["name"]: tool for tool in tools or []
61
+ if isinstance(tool, dict) and isinstance(tool.get("name"), str)}
62
+ self.source = source
63
+ self.account = account if isinstance(account, dict) else None
64
+ self.capabilities = capabilities if isinstance(capabilities, dict) else {}
65
+ self.flags = flags if isinstance(flags, dict) else {}
66
+ self.read_at = time.monotonic()
67
+
68
+ @classmethod
69
+ def from_manifest(cls, manifest, source="server"):
70
+ return cls(manifest.get("tools"), source, account=manifest.get("account"),
71
+ capabilities=manifest.get("capabilities"), flags=manifest.get("flags"))
72
+
73
+ @classmethod
74
+ def from_snapshot(cls):
75
+ value = snapshot()
76
+ return cls(value["tools"], "snapshot", capabilities=value.get("capabilities"))
77
+
78
+ def visible(self):
79
+ """Descriptors of the tools the agent sees, render first."""
80
+ return sorted((tool for tool in self.tools.values() if usable(tool, self.account)), key=_order)
81
+
82
+ def find(self, name):
83
+ """The visible descriptor for an agent-facing or catalog tool name, or None."""
84
+ for tool in self.visible():
85
+ if name in {tool["name"], agent_name(tool["name"])}:
86
+ return tool
87
+ return None
88
+
89
+ def settings(self):
90
+ return dict((self.account or {}).get("settings") or {})
loopcut/cli.py ADDED
@@ -0,0 +1,265 @@
1
+ """loopcut: the same calls as the MCP tools, for shells, scripts and CI.
2
+
3
+ loopcut login | logout | account | tools
4
+ loopcut settings [--private-mode on|off] [--no-generation on|off]
5
+ loopcut quote TOOL [--input JSON] [--file FIELD=PATH …] price a request; spends nothing
6
+ loopcut run TOOL [--input JSON] [--file FIELD=PATH …] [--yes | --max-credits N] [--out DIR]
7
+ loopcut start QUOTE_ID | status JOB_ID [--wait] | fetch JOB_ID [--out DIR] | cancel JOB_ID
8
+ loopcut render scene.blend [--frames 1-120] [--samples 256] … [--yes | --max-credits N]
9
+ loopcut mcp stdio MCP server
10
+
11
+ TOOL is a name from `loopcut tools`, e.g. asset_search, image_to_3d, remesh, sound_effect,
12
+ voice_line; render takes --blend. Results print as JSON on stdout; progress and prompts go to stderr.
13
+ """
14
+
15
+ import argparse
16
+ import contextlib
17
+ import json
18
+ import re
19
+ import sys
20
+
21
+ from . import __version__, mcp_server
22
+ from . import catalog as catalogs
23
+ from .service import RENDER, TERMINAL, LoopcutError, Service
24
+
25
+
26
+ def _frames(value):
27
+ match = re.fullmatch(r"(-?\d+)(?:-(-?\d+))?(?::(\d+))?", value)
28
+ if not match:
29
+ raise argparse.ArgumentTypeError("use N, START-END or START-END:STEP")
30
+ start = int(match[1])
31
+ result = {"frame_start": start, "frame_end": int(match[2]) if match[2] else start}
32
+ if match[3]:
33
+ result["frame_step"] = int(match[3])
34
+ return result
35
+
36
+
37
+ def _json_object(value):
38
+ try:
39
+ parsed = json.loads(value)
40
+ except ValueError as ex:
41
+ raise argparse.ArgumentTypeError(f"not JSON: {ex}") from None
42
+ if not isinstance(parsed, dict):
43
+ raise argparse.ArgumentTypeError("must be a JSON object, e.g. '{\"prompt\": \"door slam\"}'")
44
+ return parsed
45
+
46
+
47
+ def _file(value):
48
+ field, sep, path = value.partition("=")
49
+ if not sep or not field or not path:
50
+ raise argparse.ArgumentTypeError("use FIELD=PATH, e.g. image=photo.png")
51
+ return field, path
52
+
53
+
54
+ def _switch(value):
55
+ if value not in {"on", "off"}:
56
+ raise argparse.ArgumentTypeError("use on or off")
57
+ return value == "on"
58
+
59
+
60
+ def _settings(args):
61
+ settings = dict(args.frames or {})
62
+ for name in ("engine", "samples", "resolution_pct", "camera"):
63
+ if getattr(args, name) is not None:
64
+ settings[name] = getattr(args, name)
65
+ if args.no_denoise:
66
+ settings["denoise"] = False
67
+ if args.turntable is not None:
68
+ settings["turntable"] = {"targets": args.turntable} if args.turntable else {}
69
+ return settings
70
+
71
+
72
+ def _render_options(parser):
73
+ parser.add_argument("blend", help="saved .blend file")
74
+ parser.add_argument("--frames", type=_frames, help="N, START-END or START-END:STEP (default: frame 1)")
75
+ parser.add_argument("--engine", choices=("cycles", "eevee"))
76
+ parser.add_argument("--samples", type=int)
77
+ parser.add_argument("--resolution-pct", type=int, dest="resolution_pct")
78
+ parser.add_argument("--camera")
79
+ parser.add_argument("--no-denoise", action="store_true")
80
+ parser.add_argument("--turntable", nargs="*", metavar="OBJECT", help="360° turntable of these objects (none: whole scene)")
81
+ parser.add_argument("--blender", help="Blender executable to pack with")
82
+ parser.add_argument("--confirm-token", help="token from an approved daily_limit_reached or rate_limited refusal")
83
+
84
+
85
+ def _tool_options(parser):
86
+ parser.add_argument("tool", help="tool name from `loopcut tools`")
87
+ parser.add_argument("--input", type=_json_object, default={}, help="the tool's inputs as a JSON object")
88
+ parser.add_argument("--file", type=_file, action="append", default=[], metavar="FIELD=PATH",
89
+ help="a file input as a local path, e.g. image=photo.png")
90
+ parser.add_argument("--blend", help="render only: the saved .blend file")
91
+ parser.add_argument("--blender", help="render only: Blender executable to pack with")
92
+ parser.add_argument("--confirm-token", help="token from an approved daily_limit_reached or rate_limited refusal")
93
+
94
+
95
+ def _approve(parser):
96
+ approve = parser.add_mutually_exclusive_group()
97
+ approve.add_argument("--yes", action="store_true", help="start without asking")
98
+ approve.add_argument("--max-credits", type=int, help="start without asking if the quote is at most this")
99
+
100
+
101
+ def _parser():
102
+ parser = argparse.ArgumentParser(prog="loopcut", description="Loopcut's cloud tools for Blender: render, asset "
103
+ "search, image to 3D, remesh, sound and voice, paid with Loopcut credits.")
104
+ parser.add_argument("--version", action="version", version=f"loopcut {__version__}")
105
+ commands = parser.add_subparsers(dest="command", required=True)
106
+ commands.add_parser("login", help="sign in to Loopcut in the browser")
107
+ commands.add_parser("logout", help="forget the stored Loopcut key")
108
+ commands.add_parser("account", help="plan, credits, settings and which tools the account can use")
109
+ commands.add_parser("tools", help="the open cloud tools and their inputs")
110
+ settings = commands.add_parser("settings", help="show or change Private mode and no-generation")
111
+ settings.add_argument("--private-mode", type=_switch, metavar="on|off")
112
+ settings.add_argument("--no-generation", type=_switch, metavar="on|off")
113
+ _tool_options(commands.add_parser("quote", help="price a request (spends nothing)"))
114
+ run = commands.add_parser("run", help="quote, start, wait and download in one go")
115
+ _tool_options(run)
116
+ run.add_argument("--out", help="folder to save into")
117
+ _approve(run)
118
+ start = commands.add_parser("start", help="start a quoted job (spends credits)")
119
+ start.add_argument("quote_id")
120
+ start.add_argument("--confirm-token")
121
+ status = commands.add_parser("status", help="job status")
122
+ status.add_argument("job_id")
123
+ status.add_argument("--wait", action="store_true", help="wait until the job finishes")
124
+ fetch = commands.add_parser("fetch", help="download a finished job's results")
125
+ fetch.add_argument("job_id")
126
+ fetch.add_argument("--out", help="folder to save into (default: loopcut_outputs/<job>, renders next to the .blend)")
127
+ cancel = commands.add_parser("cancel", help="cancel a job; unspent credits are refunded")
128
+ cancel.add_argument("job_id")
129
+ render = commands.add_parser("render", help="render a .blend: quote, start, wait and download")
130
+ _render_options(render)
131
+ render.add_argument("--out", help="folder to save into")
132
+ _approve(render)
133
+ commands.add_parser("mcp", help="run the stdio MCP server (for Claude Code, Codex, Cursor, Claude Desktop)")
134
+ return parser
135
+
136
+
137
+ def _say(message):
138
+ print(message, file=sys.stderr, flush=True)
139
+
140
+
141
+ def _approved(quote, args):
142
+ if quote["credits"] == 0 or args.yes or (args.max_credits is not None and quote["credits"] <= args.max_credits):
143
+ return True
144
+ if args.max_credits is not None or not sys.stdin.isatty():
145
+ _say(f"Quote {quote['credits']} credits is over the limit or needs approval; not started.")
146
+ return False
147
+ what = f"{quote['frames']} frame(s)" if "frames" in quote else quote["tool"]
148
+ answer = input(f"Run {what} for at most {quote['credits']} credits (${quote['usd']:.2f})? [y/N] ")
149
+ return answer.strip().lower() in {"y", "yes"}
150
+
151
+
152
+ def _quote(service, args):
153
+ if catalogs.agent_name(args.tool) == catalogs.agent_name(RENDER) or args.tool == RENDER:
154
+ if not args.blend:
155
+ raise LoopcutError("render needs --blend PATH (or use: loopcut render PATH).", "invalid_input", {"field": "blend"})
156
+ return service.quote(args.blend, args.input, blender=args.blender, confirm_token=args.confirm_token)
157
+ return service.quote_tool(args.tool, args.input, dict(args.file), confirm_token=args.confirm_token, wait=None)
158
+
159
+
160
+ def _until_done(service, job, out):
161
+ while job["status"] not in TERMINAL:
162
+ progress = job.get("progress") or {}
163
+ _say(f"{job['status']} {progress.get('pct', 0)}% {progress.get('message', '')}".rstrip())
164
+ job = service.status(job["job_id"], wait=30)
165
+ if job["status"] != "succeeded":
166
+ return job
167
+ return {**job, **service.fetch(job["job_id"], out)}
168
+
169
+
170
+ def _run_quote(service, quote, args):
171
+ for warning in quote.get("warnings") or []:
172
+ _say("warning: " + warning)
173
+ if quote.get("status") != "quoted" or not _approved(quote, args):
174
+ return quote
175
+ job = service.start(quote["quote_id"], confirm_token=args.confirm_token, wait=0, out_dir=args.out, download=False)
176
+ return _until_done(service, job, args.out)
177
+
178
+
179
+ def _tools(service):
180
+ rows = []
181
+ for descriptor in service.catalog().visible():
182
+ schema = descriptor["input_schema"]
183
+ required = set(schema.get("required") or [])
184
+ rows.append({"tool": catalogs.agent_name(descriptor["name"]), "title": descriptor.get("title"),
185
+ "inputs": [name + ("*" if name in required else "") for name in schema.get("properties") or {}],
186
+ "files": [spec["field"] + ("*" if spec.get("required") else "")
187
+ for spec in descriptor.get("file_inputs") or []],
188
+ "usual_credits": (descriptor.get("cost") or {}).get("credits_estimate")})
189
+ return {"tools": rows, "catalog": service.catalog().source, "note": "* required. Quotes give the exact price."}
190
+
191
+
192
+ def _sign_in(service):
193
+ result = service.login(wait=0)
194
+ while not result.get("signed_in"):
195
+ _say(f"Open {result['verification_url']} and approve code {result['user_code']}. Waiting…")
196
+ result = service.login(wait=60)
197
+ return result
198
+
199
+
200
+ def _run_signed_in(service, args):
201
+ """Run the command; signed out at a terminal (and without LOOPCUT_API_KEY), sign in first and go on."""
202
+ try:
203
+ return _run(service, args)
204
+ except LoopcutError as ex:
205
+ if ex.code not in {"sign_in_required", "invalid_api_key"} or args.command == "login" \
206
+ or not service.can_sign_in() or not sys.stdin.isatty():
207
+ raise
208
+ _say("Not signed in to Loopcut yet; opening the sign-in page.")
209
+ _sign_in(service)
210
+ return _run(service, args)
211
+
212
+
213
+ def _run(service, args):
214
+ command = args.command
215
+ if command == "login":
216
+ return _sign_in(service)
217
+ if command == "logout":
218
+ return service.logout()
219
+ if command == "account":
220
+ return service.account()
221
+ if command == "tools":
222
+ return _tools(service)
223
+ if command == "settings":
224
+ return service.settings(private_mode=args.private_mode, no_generation=args.no_generation)
225
+ if command == "quote":
226
+ return _quote(service, args)
227
+ if command == "run":
228
+ quote = _quote(service, args)
229
+ if quote.get("status") != "quoted":
230
+ return quote # A free tool already ran through.
231
+ return _run_quote(service, quote, args)
232
+ if command == "start":
233
+ return service.start(args.quote_id, confirm_token=args.confirm_token, wait=0, download=False)
234
+ if command == "status":
235
+ if not args.wait:
236
+ return service.status(args.job_id)
237
+ job = service.status(args.job_id)
238
+ while job["status"] not in TERMINAL:
239
+ job = service.status(args.job_id, wait=30)
240
+ return job
241
+ if command == "fetch":
242
+ return service.fetch(args.job_id, args.out)
243
+ if command == "cancel":
244
+ return service.cancel(args.job_id)
245
+ if command == "render":
246
+ quote = service.quote(args.blend, _settings(args), blender=args.blender, confirm_token=args.confirm_token)
247
+ return _run_quote(service, quote, args)
248
+ raise AssertionError(command)
249
+
250
+
251
+ def main(argv=None):
252
+ args = _parser().parse_args(argv)
253
+ if args.command == "mcp":
254
+ return mcp_server.main()
255
+ output = sys.stdout
256
+ try:
257
+ with contextlib.redirect_stdout(sys.stderr): # Only the JSON result goes to stdout.
258
+ result = _run_signed_in(Service(), args)
259
+ except LoopcutError as ex:
260
+ print(json.dumps(ex.as_dict(), indent=2, ensure_ascii=False), file=output)
261
+ return 1
262
+ except KeyboardInterrupt:
263
+ return 130
264
+ print(json.dumps(result, indent=2, ensure_ascii=False), file=output)
265
+ return 0