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