janction-render 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.
- janction_render/__init__.py +3 -0
- janction_render/cli.py +264 -0
- janction_render/client.py +249 -0
- janction_render/mcp_server.py +358 -0
- janction_render-0.1.0.dist-info/METADATA +118 -0
- janction_render-0.1.0.dist-info/RECORD +10 -0
- janction_render-0.1.0.dist-info/WHEEL +5 -0
- janction_render-0.1.0.dist-info/entry_points.txt +3 -0
- janction_render-0.1.0.dist-info/licenses/LICENSE +21 -0
- janction_render-0.1.0.dist-info/top_level.txt +1 -0
janction_render/cli.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""コマンド。MCP と同じ機能を同じ受付で(要件 F-01)。
|
|
2
|
+
|
|
3
|
+
janction-render preview scene.py # 試し描き 1 コマ → PNG
|
|
4
|
+
janction-render render scene.blend --frames 1-240 # 仕上げ → MP4(1 コマなら PNG)
|
|
5
|
+
janction-render status JOB
|
|
6
|
+
janction-render download JOB --out DIR [--wait 600]
|
|
7
|
+
janction-render cancel JOB
|
|
8
|
+
janction-render jobs
|
|
9
|
+
janction-render info
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from .client import Client, ClientError, parse_frames
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _j(obj: Any) -> str:
|
|
23
|
+
return json.dumps(obj, ensure_ascii=False, indent=2)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _size(s: str) -> tuple[int, int]:
|
|
27
|
+
try:
|
|
28
|
+
w, h = s.lower().split("x")
|
|
29
|
+
return int(w), int(h)
|
|
30
|
+
except ValueError:
|
|
31
|
+
raise argparse.ArgumentTypeError("size must look like 1920x1080")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _frames(s: str) -> tuple[int, int]:
|
|
35
|
+
try:
|
|
36
|
+
if "-" in s:
|
|
37
|
+
a, b = s.split("-", 1)
|
|
38
|
+
return int(a), int(b)
|
|
39
|
+
return int(s), int(s)
|
|
40
|
+
except ValueError:
|
|
41
|
+
raise argparse.ArgumentTypeError("frames must look like 1-240 or 12")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _progress(j: dict[str, Any]) -> None:
|
|
45
|
+
p = j["progress"]
|
|
46
|
+
print(f" {j['status']}: {p['frames_done']}/{p['frames_total']} frames"
|
|
47
|
+
+ (f", {p['queue_ahead']} chunks ahead" if p["queue_ahead"] else ""), file=sys.stderr)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _scene(c: Client, args: argparse.Namespace) -> str:
|
|
51
|
+
if args.scene_id:
|
|
52
|
+
return args.scene_id
|
|
53
|
+
if not args.scene:
|
|
54
|
+
raise SystemExit("give a scene file (.blend or .py) or --scene-id")
|
|
55
|
+
up = c.upload(args.scene)
|
|
56
|
+
print(f"uploaded {up['name']} -> scene_id {up['scene_id']}", file=sys.stderr)
|
|
57
|
+
return up["scene_id"]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def cmd_inspect(c: Client, args: argparse.Namespace) -> int:
|
|
61
|
+
sid = _scene(c, args)
|
|
62
|
+
j = c.submit(sid, kind="info")
|
|
63
|
+
if j["status"] != "done":
|
|
64
|
+
print(f"job {j['job_id']} queued (reads the scene, no render)", file=sys.stderr)
|
|
65
|
+
j = c.wait(j["job_id"], timeout=args.timeout, on_progress=_progress)
|
|
66
|
+
if j["status"] != "done":
|
|
67
|
+
print(_j({k: j.get(k) for k in ("job_id", "status", "error", "log_tail")}))
|
|
68
|
+
return 1
|
|
69
|
+
info = dict(j.get("info") or {})
|
|
70
|
+
info["scene_id"] = sid
|
|
71
|
+
if j.get("warnings"):
|
|
72
|
+
info["blender_warnings"] = j["warnings"]
|
|
73
|
+
print(_j(info))
|
|
74
|
+
return 0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def cmd_preview(c: Client, args: argparse.Namespace) -> int:
|
|
78
|
+
sid = _scene(c, args)
|
|
79
|
+
w, h = args.size
|
|
80
|
+
frames = parse_frames(args.frames, default=args.frame)
|
|
81
|
+
j = c.submit(sid, kind="preview", frames=frames, camera=args.camera, width=w, height=h,
|
|
82
|
+
samples=args.samples)
|
|
83
|
+
print(f"job {j['job_id']} queued: frames {frames} (estimate ~{j['estimate']['seconds']}s)", file=sys.stderr)
|
|
84
|
+
j = c.wait(j["job_id"], timeout=args.timeout, on_progress=_progress)
|
|
85
|
+
if j["status"] != "done":
|
|
86
|
+
print(_j({k: j.get(k) for k in ("job_id", "status", "error", "log_tail", "warnings")}))
|
|
87
|
+
return 1
|
|
88
|
+
out = Path(args.out) if args.out else Path("render_out") / j["job_id"]
|
|
89
|
+
paths = c.download(j["job_id"], out)
|
|
90
|
+
print(_j({"job_id": j["job_id"], "scene_id": sid, "frames": frames, "files": [str(p) for p in paths],
|
|
91
|
+
"warnings": j.get("warnings") or [], "gpu_seconds": j["gpu_seconds"], "device": j["device"],
|
|
92
|
+
"expires_at": j["expires_at"]}))
|
|
93
|
+
return 0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def cmd_render(c: Client, args: argparse.Namespace) -> int:
|
|
97
|
+
sid = _scene(c, args)
|
|
98
|
+
w, h = args.size
|
|
99
|
+
fs, fe = args.frames
|
|
100
|
+
j = c.submit(sid, kind="final", frame_start=fs, frame_end=fe, width=w, height=h,
|
|
101
|
+
samples=args.samples, fps=args.fps, output=args.output, camera=args.camera)
|
|
102
|
+
print(f"job {j['job_id']} queued: {fe - fs + 1} frames in {j['progress']['chunks_total']} chunks, "
|
|
103
|
+
f"estimate ~{j['estimate']['seconds']}s", file=sys.stderr)
|
|
104
|
+
if not args.wait:
|
|
105
|
+
print(_j({"job_id": j["job_id"], "scene_id": sid, "estimate": j["estimate"]}))
|
|
106
|
+
return 0
|
|
107
|
+
j = c.wait(j["job_id"], timeout=args.timeout, on_progress=_progress)
|
|
108
|
+
if j["status"] != "done":
|
|
109
|
+
print(_j({k: j.get(k) for k in ("job_id", "status", "error", "log_tail")}))
|
|
110
|
+
return 1
|
|
111
|
+
out = Path(args.out) if args.out else Path("render_out") / j["job_id"]
|
|
112
|
+
paths = c.download(j["job_id"], out)
|
|
113
|
+
print(_j({"job_id": j["job_id"], "scene_id": sid, "files": [str(p) for p in paths],
|
|
114
|
+
"warnings": j.get("warnings") or [], "gpu_seconds": j["gpu_seconds"], "device": j["device"],
|
|
115
|
+
"expires_at": j["expires_at"]}))
|
|
116
|
+
return 0
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def cmd_status(c: Client, args: argparse.Namespace) -> int:
|
|
120
|
+
print(_j(c.job(args.job)))
|
|
121
|
+
return 0
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def cmd_download(c: Client, args: argparse.Namespace) -> int:
|
|
125
|
+
j = c.wait(args.job, timeout=args.wait, on_progress=_progress) if args.wait else c.job(args.job)
|
|
126
|
+
out = Path(args.out) if args.out else Path("render_out") / args.job
|
|
127
|
+
paths = c.download(args.job, out)
|
|
128
|
+
print(_j({"job_id": args.job, "status": j["status"], "files": [str(p) for p in paths]}))
|
|
129
|
+
return 0 if j["status"] == "done" else 1
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def cmd_cancel(c: Client, args: argparse.Namespace) -> int:
|
|
133
|
+
j = c.cancel(args.job)
|
|
134
|
+
print(_j({"job_id": j["job_id"], "status": j["status"]}))
|
|
135
|
+
return 0
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def cmd_jobs(c: Client, args: argparse.Namespace) -> int:
|
|
139
|
+
rows = c.jobs(args.limit)
|
|
140
|
+
for j in rows:
|
|
141
|
+
p = j["progress"]
|
|
142
|
+
print(f"{j['job_id']} {j['kind']:<7} {j['status']:<9} {j['frame_start']}-{j['frame_end']} "
|
|
143
|
+
f"{p['frames_done']}/{p['frames_total']} gpu {j['gpu_seconds']}s")
|
|
144
|
+
return 0
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def cmd_info(c: Client, args: argparse.Namespace) -> int:
|
|
148
|
+
print(_j({"server": c.server, "health": c.health(), "me": c.me()}))
|
|
149
|
+
return 0
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def cmd_balance(c: Client, args: argparse.Namespace) -> int:
|
|
153
|
+
me = c.me()
|
|
154
|
+
synced = c.billing_sync() if me["billing"]["enabled"] else {}
|
|
155
|
+
me = c.me()
|
|
156
|
+
print(_j({"balance_yen": me["balance_yen"], "free_previews_left": me["free_previews_left"],
|
|
157
|
+
"billing": me["billing"], "just_credited": synced.get("credited", []),
|
|
158
|
+
"ledger": c.ledger(args.limit)["entries"]}))
|
|
159
|
+
return 0
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def cmd_topup(c: Client, args: argparse.Namespace) -> int:
|
|
163
|
+
co = c.checkout(args.yen)
|
|
164
|
+
print(f"open this page in a browser and pay: {co['checkout_url']}", file=sys.stderr)
|
|
165
|
+
print(_j(co))
|
|
166
|
+
return 0
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
170
|
+
p = argparse.ArgumentParser(prog="janction-render",
|
|
171
|
+
description="Render Blender scenes on JANCTION GPUs from the terminal.")
|
|
172
|
+
p.add_argument("--server", default=None, help="server URL (default: JANCTION_RENDER_SERVER or http://127.0.0.1:8340)")
|
|
173
|
+
p.add_argument("--key", default=None, help="API key (default: JANCTION_RENDER_API_KEY or an auto-issued temporary key)")
|
|
174
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
175
|
+
|
|
176
|
+
s = sub.add_parser("inspect", help="read the scene without rendering (cameras, frame range, missing files)")
|
|
177
|
+
s.add_argument("scene", nargs="?", help=".blend file or bpy Python script")
|
|
178
|
+
s.add_argument("--scene-id", default=None, help="reuse an uploaded scene")
|
|
179
|
+
s.add_argument("--timeout", type=float, default=180)
|
|
180
|
+
s.set_defaults(fn=cmd_inspect)
|
|
181
|
+
|
|
182
|
+
s = sub.add_parser("preview", help="render 1-4 preview frames (fast, up to 720p; several frames are tiled)")
|
|
183
|
+
s.add_argument("scene", nargs="?", help=".blend file or bpy Python script")
|
|
184
|
+
s.add_argument("--scene-id", default=None, help="reuse an uploaded scene")
|
|
185
|
+
s.add_argument("--frame", type=int, default=1)
|
|
186
|
+
s.add_argument("--frames", default="", help="e.g. 1,8,16,24 or 1-24 (4 evenly spaced) -> one tiled image")
|
|
187
|
+
s.add_argument("--camera", default=None)
|
|
188
|
+
s.add_argument("--size", type=_size, default=(1280, 720))
|
|
189
|
+
s.add_argument("--samples", type=int, default=16)
|
|
190
|
+
s.add_argument("--out", default=None)
|
|
191
|
+
s.add_argument("--timeout", type=float, default=300)
|
|
192
|
+
s.set_defaults(fn=cmd_preview)
|
|
193
|
+
|
|
194
|
+
s = sub.add_parser("render", help="render final frames (MP4 or PNG)")
|
|
195
|
+
s.add_argument("scene", nargs="?")
|
|
196
|
+
s.add_argument("--scene-id", default=None)
|
|
197
|
+
s.add_argument("--frames", type=_frames, required=True, help="e.g. 1-240 or 12")
|
|
198
|
+
s.add_argument("--size", type=_size, default=(1920, 1080))
|
|
199
|
+
s.add_argument("--samples", type=int, default=128)
|
|
200
|
+
s.add_argument("--fps", type=int, default=24)
|
|
201
|
+
s.add_argument("--output", choices=["auto", "png", "mp4"], default="auto")
|
|
202
|
+
s.add_argument("--camera", default=None)
|
|
203
|
+
s.add_argument("--out", default=None)
|
|
204
|
+
s.add_argument("--wait", action="store_true", help="wait until done and download")
|
|
205
|
+
s.add_argument("--timeout", type=float, default=7200)
|
|
206
|
+
s.set_defaults(fn=cmd_render)
|
|
207
|
+
|
|
208
|
+
s = sub.add_parser("status")
|
|
209
|
+
s.add_argument("job")
|
|
210
|
+
s.set_defaults(fn=cmd_status)
|
|
211
|
+
|
|
212
|
+
s = sub.add_parser("download")
|
|
213
|
+
s.add_argument("job")
|
|
214
|
+
s.add_argument("--out", default=None)
|
|
215
|
+
s.add_argument("--wait", type=float, default=0, help="seconds to wait for the job first")
|
|
216
|
+
s.set_defaults(fn=cmd_download)
|
|
217
|
+
|
|
218
|
+
s = sub.add_parser("cancel")
|
|
219
|
+
s.add_argument("job")
|
|
220
|
+
s.set_defaults(fn=cmd_cancel)
|
|
221
|
+
|
|
222
|
+
s = sub.add_parser("jobs")
|
|
223
|
+
s.add_argument("--limit", type=int, default=20)
|
|
224
|
+
s.set_defaults(fn=cmd_jobs)
|
|
225
|
+
|
|
226
|
+
s = sub.add_parser("info")
|
|
227
|
+
s.set_defaults(fn=cmd_info)
|
|
228
|
+
|
|
229
|
+
s = sub.add_parser("balance", help="credit balance, price, and recent ledger (also confirms payments)")
|
|
230
|
+
s.add_argument("--limit", type=int, default=20)
|
|
231
|
+
s.set_defaults(fn=cmd_balance)
|
|
232
|
+
|
|
233
|
+
s = sub.add_parser("topup", help="get a Stripe checkout link to add credit")
|
|
234
|
+
s.add_argument("--yen", type=int, default=None, help="amount (default: the minimum)")
|
|
235
|
+
s.set_defaults(fn=cmd_topup)
|
|
236
|
+
return p
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def main(argv: list[str] | None = None) -> int:
|
|
240
|
+
args = build_parser().parse_args(argv)
|
|
241
|
+
c = Client(server=args.server, api_key=args.key, client="cli")
|
|
242
|
+
try:
|
|
243
|
+
return int(args.fn(c, args))
|
|
244
|
+
except ClientError as exc:
|
|
245
|
+
if exc.status == 429 and exc.error == "quota_exceeded":
|
|
246
|
+
print(f"quota: {exc.detail}", file=sys.stderr)
|
|
247
|
+
print(_j({k: exc.extra.get(k) for k in ("scope", "gpu_seconds_used_today", "gpu_seconds_per_day", "resets_at")}))
|
|
248
|
+
return 4
|
|
249
|
+
pay = exc.payment()
|
|
250
|
+
if pay:
|
|
251
|
+
print(f"payment required: {exc.detail}", file=sys.stderr)
|
|
252
|
+
print(f"open this page in a browser and pay, then run the command again: {pay['checkout_url']}",
|
|
253
|
+
file=sys.stderr)
|
|
254
|
+
print(_j(pay))
|
|
255
|
+
return 3
|
|
256
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
257
|
+
return 2
|
|
258
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
259
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
260
|
+
return 2
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
if __name__ == "__main__":
|
|
264
|
+
sys.exit(main())
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""受付を呼ぶ側の共通部分。MCP とコマンドの両方がこれを使う(要件 F-01)。
|
|
2
|
+
|
|
3
|
+
環境変数:
|
|
4
|
+
JANCTION_RENDER_SERVER 受付の URL(既定 http://127.0.0.1:8340)
|
|
5
|
+
JANCTION_RENDER_API_KEY API キー。無ければ受付に一時キーをもらい ~/.janction-render.json に覚える
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Callable, Optional
|
|
14
|
+
|
|
15
|
+
import requests
|
|
16
|
+
|
|
17
|
+
DEFAULT_SERVER = "http://127.0.0.1:8340"
|
|
18
|
+
CACHE = Path.home() / ".janction-render.json"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def parse_frames(spec: str, default: int = 1, count: int = 4) -> list[int]:
|
|
22
|
+
"""試し描きのコマの指定を配列にする。
|
|
23
|
+
|
|
24
|
+
'1,8,16,24' → そのまま/'1-24' → 両端を含む count コマを等間隔で/'12' → [12]/'' → [default]
|
|
25
|
+
"""
|
|
26
|
+
s = (spec or "").strip()
|
|
27
|
+
if not s:
|
|
28
|
+
return [int(default)]
|
|
29
|
+
if "," in s:
|
|
30
|
+
vals = [int(x) for x in s.split(",") if x.strip()]
|
|
31
|
+
elif "-" in s:
|
|
32
|
+
a, b = s.split("-", 1)
|
|
33
|
+
a, b = int(a), int(b)
|
|
34
|
+
if b < a:
|
|
35
|
+
raise ValueError("frames: end must be >= start")
|
|
36
|
+
n = min(count, b - a + 1)
|
|
37
|
+
vals = [a + round(i * (b - a) / (n - 1)) for i in range(n)] if n > 1 else [a]
|
|
38
|
+
else:
|
|
39
|
+
vals = [int(s)]
|
|
40
|
+
out: list[int] = []
|
|
41
|
+
for v in vals:
|
|
42
|
+
if v < 0:
|
|
43
|
+
raise ValueError("frames must be >= 0")
|
|
44
|
+
if v not in out:
|
|
45
|
+
out.append(v)
|
|
46
|
+
return out
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ClientError(RuntimeError):
|
|
50
|
+
def __init__(self, status: int, error: str, detail: str, extra: Optional[dict[str, Any]] = None):
|
|
51
|
+
super().__init__(f"{error}: {detail}" if detail else error)
|
|
52
|
+
self.status = status
|
|
53
|
+
self.error = error
|
|
54
|
+
self.detail = detail
|
|
55
|
+
self.extra = extra or {} # 402 のときは checkout_url / needed_yen / balance_yen など
|
|
56
|
+
|
|
57
|
+
def payment(self) -> Optional[dict[str, Any]]:
|
|
58
|
+
"""残高不足(402)なら、決済ページのリンクなどをまとめて返す。"""
|
|
59
|
+
if self.status != 402:
|
|
60
|
+
return None
|
|
61
|
+
out = {"payment_required": True, "detail": self.detail,
|
|
62
|
+
**{k: self.extra.get(k) for k in ("needed_yen", "balance_yen", "topup_yen", "checkout_url",
|
|
63
|
+
"session_id", "yen_per_gpu_second", "how")}}
|
|
64
|
+
if self.extra.get("accepts"):
|
|
65
|
+
# x402(USDC)でも払える受付。対応しているエージェントは accepts から X-PAYMENT を組む
|
|
66
|
+
out["x402"] = {"x402Version": self.extra.get("x402Version"), "accepts": self.extra["accepts"],
|
|
67
|
+
"how": self.extra.get("how_x402")}
|
|
68
|
+
return out
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Client:
|
|
72
|
+
def __init__(self, server: Optional[str] = None, api_key: Optional[str] = None, client: str = "") -> None:
|
|
73
|
+
from . import __version__
|
|
74
|
+
|
|
75
|
+
self.server = (server or os.environ.get("JANCTION_RENDER_SERVER") or DEFAULT_SERVER).rstrip("/")
|
|
76
|
+
self._key = (api_key or os.environ.get("JANCTION_RENDER_API_KEY") or "").strip() or None
|
|
77
|
+
self.s = requests.Session()
|
|
78
|
+
# 受付に名乗る(どの入口から来たかを数える。個人を特定するものは入れない)
|
|
79
|
+
self.s.headers["X-Client"] = f"{client or 'api'} {__version__}"
|
|
80
|
+
|
|
81
|
+
# ---- 鍵 ----------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
def key(self) -> str:
|
|
84
|
+
if self._key:
|
|
85
|
+
return self._key
|
|
86
|
+
cache = self._read_cache()
|
|
87
|
+
cached = cache.get(self.server)
|
|
88
|
+
if cached:
|
|
89
|
+
self._key = cached
|
|
90
|
+
return cached
|
|
91
|
+
r = self.s.post(f"{self.server}/v1/keys", json={"label": self.s.headers.get("X-Client", "auto")}, timeout=15)
|
|
92
|
+
self._raise(r)
|
|
93
|
+
self._key = r.json()["api_key"]
|
|
94
|
+
cache[self.server] = self._key
|
|
95
|
+
self._write_cache(cache)
|
|
96
|
+
return self._key
|
|
97
|
+
|
|
98
|
+
def forget_key(self) -> None:
|
|
99
|
+
cache = self._read_cache()
|
|
100
|
+
cache.pop(self.server, None)
|
|
101
|
+
self._write_cache(cache)
|
|
102
|
+
self._key = None
|
|
103
|
+
|
|
104
|
+
@staticmethod
|
|
105
|
+
def _read_cache() -> dict[str, str]:
|
|
106
|
+
try:
|
|
107
|
+
data = json.loads(CACHE.read_text(encoding="utf-8"))
|
|
108
|
+
return data if isinstance(data, dict) else {}
|
|
109
|
+
except (OSError, ValueError):
|
|
110
|
+
return {}
|
|
111
|
+
|
|
112
|
+
@staticmethod
|
|
113
|
+
def _write_cache(cache: dict[str, str]) -> None:
|
|
114
|
+
try:
|
|
115
|
+
CACHE.write_text(json.dumps(cache, indent=1), encoding="utf-8")
|
|
116
|
+
try:
|
|
117
|
+
os.chmod(CACHE, 0o600)
|
|
118
|
+
except OSError:
|
|
119
|
+
pass
|
|
120
|
+
except OSError:
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
# ---- 共通 --------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
@staticmethod
|
|
126
|
+
def _raise(r: requests.Response) -> None:
|
|
127
|
+
if r.status_code < 400:
|
|
128
|
+
return
|
|
129
|
+
try:
|
|
130
|
+
body = r.json()
|
|
131
|
+
except ValueError:
|
|
132
|
+
raise ClientError(r.status_code, f"http_{r.status_code}", r.text[:300])
|
|
133
|
+
raise ClientError(r.status_code, str(body.get("error", r.status_code)), str(body.get("detail", "")),
|
|
134
|
+
extra=body if isinstance(body, dict) else None)
|
|
135
|
+
|
|
136
|
+
def _headers(self) -> dict[str, str]:
|
|
137
|
+
return {"X-API-Key": self.key()}
|
|
138
|
+
|
|
139
|
+
def _req(self, method: str, path: str, timeout: float = 30.0, **kw: Any) -> Any:
|
|
140
|
+
r = self.s.request(method, f"{self.server}{path}", headers=self._headers(), timeout=timeout, **kw)
|
|
141
|
+
if r.status_code == 401 and not os.environ.get("JANCTION_RENDER_API_KEY"):
|
|
142
|
+
# 覚えていた一時キーが受付側で消えた(受付の作り直しなど)。1 回だけ取り直す。
|
|
143
|
+
self.forget_key()
|
|
144
|
+
r = self.s.request(method, f"{self.server}{path}", headers=self._headers(), timeout=timeout, **kw)
|
|
145
|
+
self._raise(r)
|
|
146
|
+
return r.json()
|
|
147
|
+
|
|
148
|
+
# ---- 機能 --------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
def health(self) -> dict[str, Any]:
|
|
151
|
+
r = self.s.get(f"{self.server}/v1/health", timeout=10)
|
|
152
|
+
self._raise(r)
|
|
153
|
+
return r.json()
|
|
154
|
+
|
|
155
|
+
def me(self) -> dict[str, Any]:
|
|
156
|
+
return self._req("GET", "/v1/me")
|
|
157
|
+
|
|
158
|
+
def upload(self, path: str | Path) -> dict[str, Any]:
|
|
159
|
+
p = Path(path)
|
|
160
|
+
if not p.is_file():
|
|
161
|
+
raise FileNotFoundError(f"{p} is not a file")
|
|
162
|
+
if p.suffix.lower() not in (".blend", ".py"):
|
|
163
|
+
raise ValueError("scene must be a .blend file or a Blender Python script (.py)")
|
|
164
|
+
with p.open("rb") as fh:
|
|
165
|
+
r = self.s.post(f"{self.server}/v1/files", headers=self._headers(),
|
|
166
|
+
files={"file": (p.name, fh, "application/octet-stream")}, timeout=1800)
|
|
167
|
+
self._raise(r)
|
|
168
|
+
return r.json()
|
|
169
|
+
|
|
170
|
+
def upload_text(self, name: str, text: str) -> dict[str, Any]:
|
|
171
|
+
"""文字列で受け取った bpy スクリプトを、そのままシーンとして送る(ファイルを作らなくてよい)。"""
|
|
172
|
+
import tempfile
|
|
173
|
+
|
|
174
|
+
if not name.lower().endswith(".py"):
|
|
175
|
+
name = "scene.py"
|
|
176
|
+
if not text.strip():
|
|
177
|
+
raise ValueError("the script is empty")
|
|
178
|
+
with tempfile.TemporaryDirectory() as d:
|
|
179
|
+
p = Path(d) / Path(name).name
|
|
180
|
+
p.write_text(text, encoding="utf-8")
|
|
181
|
+
return self.upload(p)
|
|
182
|
+
|
|
183
|
+
def submit(self, scene_id: str, **params: Any) -> dict[str, Any]:
|
|
184
|
+
body = {"scene_id": scene_id}
|
|
185
|
+
body.update({k: v for k, v in params.items() if v is not None})
|
|
186
|
+
return self._req("POST", "/v1/jobs", json=body)
|
|
187
|
+
|
|
188
|
+
def job(self, job_id: str) -> dict[str, Any]:
|
|
189
|
+
return self._req("GET", f"/v1/jobs/{job_id}")
|
|
190
|
+
|
|
191
|
+
def jobs(self, limit: int = 20) -> list[dict[str, Any]]:
|
|
192
|
+
return self._req("GET", f"/v1/jobs?limit={int(limit)}")["jobs"]
|
|
193
|
+
|
|
194
|
+
def cancel(self, job_id: str) -> dict[str, Any]:
|
|
195
|
+
return self._req("DELETE", f"/v1/jobs/{job_id}")
|
|
196
|
+
|
|
197
|
+
# ---- 決済 --------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
def checkout(self, amount_yen: Optional[int] = None) -> dict[str, Any]:
|
|
200
|
+
"""クレジットをチャージする決済ページのリンクを作る。"""
|
|
201
|
+
body = {"amount_yen": int(amount_yen)} if amount_yen else {}
|
|
202
|
+
return self._req("POST", "/v1/billing/checkout", json=body)
|
|
203
|
+
|
|
204
|
+
def billing_sync(self) -> dict[str, Any]:
|
|
205
|
+
"""払い終わった決済を確かめて残高に足す(戻り先のページを開かなかったときの保険)。"""
|
|
206
|
+
return self._req("POST", "/v1/billing/sync", timeout=60)
|
|
207
|
+
|
|
208
|
+
def ledger(self, limit: int = 50) -> dict[str, Any]:
|
|
209
|
+
return self._req("GET", f"/v1/ledger?limit={int(limit)}")
|
|
210
|
+
|
|
211
|
+
def wait(self, job_id: str, timeout: float, interval: float = 2.0,
|
|
212
|
+
on_progress: Optional[Callable[[dict[str, Any]], None]] = None) -> dict[str, Any]:
|
|
213
|
+
"""done / failed / canceled になるまで待つ。上限を過ぎたらそのときの状態を返す。"""
|
|
214
|
+
deadline = time.monotonic() + timeout
|
|
215
|
+
last_key = None
|
|
216
|
+
while True:
|
|
217
|
+
j = self.job(job_id)
|
|
218
|
+
key = (j["status"], j["progress"]["frames_done"])
|
|
219
|
+
if on_progress and key != last_key:
|
|
220
|
+
on_progress(j)
|
|
221
|
+
last_key = key
|
|
222
|
+
if j["status"] in ("done", "failed", "canceled") or time.monotonic() > deadline:
|
|
223
|
+
return j
|
|
224
|
+
time.sleep(interval)
|
|
225
|
+
|
|
226
|
+
def download(self, job_id: str, out_dir: str | Path, only: Optional[list[str]] = None) -> list[Path]:
|
|
227
|
+
"""成果物を out_dir に落とす。only を渡せばその名前だけ。"""
|
|
228
|
+
j = self.job(job_id)
|
|
229
|
+
out = Path(out_dir)
|
|
230
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
231
|
+
paths: list[Path] = []
|
|
232
|
+
from urllib.parse import urlsplit
|
|
233
|
+
|
|
234
|
+
for a in j["artifacts"]:
|
|
235
|
+
if only and a["name"] not in only:
|
|
236
|
+
continue
|
|
237
|
+
# 受付が返す URL は公開 URL の土台で組まれている。自分がつないだ先(SSH トンネルなど)でも
|
|
238
|
+
# 落とせるように、パスだけを取り出して自分の server に付け直す
|
|
239
|
+
parts = urlsplit(a["url"])
|
|
240
|
+
path = parts.path + (f"?{parts.query}" if parts.query else "")
|
|
241
|
+
url = f"{self.server}{path}" if parts.path.startswith("/v1/") else a["url"]
|
|
242
|
+
with self.s.get(url, headers=self._headers(), stream=True, timeout=600) as r:
|
|
243
|
+
self._raise(r)
|
|
244
|
+
dest = out / a["name"]
|
|
245
|
+
with dest.open("wb") as fh:
|
|
246
|
+
for chunk in r.iter_content(1024 * 1024):
|
|
247
|
+
fh.write(chunk)
|
|
248
|
+
paths.append(dest)
|
|
249
|
+
return paths
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""JANCTION Render の MCP サーバー(Claude Code・Codex などから使う)。
|
|
2
|
+
|
|
3
|
+
登録:
|
|
4
|
+
claude mcp add janction-render -- python /path/to/janction_render/mcp_server.py
|
|
5
|
+
(または pip install 後: claude mcp add janction-render -- janction-render-mcp)
|
|
6
|
+
|
|
7
|
+
環境変数:
|
|
8
|
+
JANCTION_RENDER_SERVER 受付の URL(既定 http://127.0.0.1:8340)
|
|
9
|
+
JANCTION_RENDER_API_KEY API キー(無ければ一時キーを自動で取る)
|
|
10
|
+
JANCTION_RENDER_OUT 落とし先の親フォルダ(既定 ~/janction-render)
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
if __package__ in (None, ""):
|
|
21
|
+
# ファイルとして直接起動されたとき(claude mcp add ... server.py)
|
|
22
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
from mcp.server.mcpserver import Image, MCPServer
|
|
26
|
+
except ImportError: # mcp 1.x
|
|
27
|
+
from mcp.server.fastmcp import FastMCP as MCPServer, Image # type: ignore
|
|
28
|
+
|
|
29
|
+
from janction_render.client import Client, ClientError, parse_frames
|
|
30
|
+
|
|
31
|
+
PREVIEW_WAIT_S = 240.0
|
|
32
|
+
INFO_WAIT_S = 150.0
|
|
33
|
+
|
|
34
|
+
mcp = MCPServer(
|
|
35
|
+
name="janction-render",
|
|
36
|
+
instructions=(
|
|
37
|
+
"Render Blender scenes on JANCTION GPUs from the terminal. Use it when the user is "
|
|
38
|
+
"building 3DCG with Blender (a .blend file or a bpy Python script) and has no GPU or "
|
|
39
|
+
"rendering locally is slow. Flow: scene_info (cameras, frame range, missing files; no "
|
|
40
|
+
"render) -> render_preview (1-4 fast frames in one image; look at it, fix the scene, "
|
|
41
|
+
"repeat) -> ask the user 'is this OK?' -> render_final (frames or MP4) -> render_status / "
|
|
42
|
+
"render_download. Inputs and results are deleted 24 hours after last use."
|
|
43
|
+
),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _log(msg: str) -> None:
|
|
48
|
+
print(f"[janction-render] {msg}", file=sys.stderr, flush=True)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _client() -> Client:
|
|
52
|
+
return Client(client="mcp")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _out_root() -> Path:
|
|
56
|
+
return Path(os.environ.get("JANCTION_RENDER_OUT") or (Path.home() / "janction-render"))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _j(obj: Any) -> str:
|
|
60
|
+
return json.dumps(obj, ensure_ascii=False, indent=2)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _quota_block(exc: ClientError) -> Optional[dict[str, Any]]:
|
|
64
|
+
"""無料ベータの 1 日の上限に当たった(429 quota_exceeded)ときの返事。"""
|
|
65
|
+
if exc.status != 429 or exc.error != "quota_exceeded":
|
|
66
|
+
return None
|
|
67
|
+
x = exc.extra
|
|
68
|
+
return {"quota_exceeded": True, "detail": exc.detail, "scope": x.get("scope"),
|
|
69
|
+
"gpu_seconds_used_today": x.get("gpu_seconds_used_today"), "gpu_seconds_per_day": x.get("gpu_seconds_per_day"),
|
|
70
|
+
"resets_at": x.get("resets_at"),
|
|
71
|
+
"next": "tell the user today's free GPU time is used up and when it resets; a smaller job (fewer frames, "
|
|
72
|
+
"lower resolution or samples) may still fit"}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _brief(j: dict[str, Any]) -> dict[str, Any]:
|
|
76
|
+
p = j["progress"]
|
|
77
|
+
out = {
|
|
78
|
+
"job_id": j["job_id"],
|
|
79
|
+
"kind": j["kind"],
|
|
80
|
+
"status": j["status"],
|
|
81
|
+
"scene_id": j["scene_id"],
|
|
82
|
+
"frames": j["frames"] if j.get("frames") else f"{j['frame_start']}-{j['frame_end']}",
|
|
83
|
+
"progress": f"{p['frames_done']}/{p['frames_total']} frames, {p['chunks_done']}/{p['chunks_total']} chunks",
|
|
84
|
+
"queue_ahead": p["queue_ahead"],
|
|
85
|
+
"gpu_seconds": j["gpu_seconds"],
|
|
86
|
+
"device": j["device"],
|
|
87
|
+
"expires_at": j["expires_at"],
|
|
88
|
+
}
|
|
89
|
+
if j.get("error"):
|
|
90
|
+
out["error"] = j["error"]
|
|
91
|
+
if j.get("log_tail"):
|
|
92
|
+
out["log_tail"] = j["log_tail"][-1500:]
|
|
93
|
+
if j.get("warnings"):
|
|
94
|
+
out["warnings"] = j["warnings"]
|
|
95
|
+
if j.get("output"):
|
|
96
|
+
out["output"] = j["output"]["name"]
|
|
97
|
+
if j.get("cost"):
|
|
98
|
+
c = j["cost"]
|
|
99
|
+
out["cost"] = ("free" if c["free"] else
|
|
100
|
+
(f"{c['charged_yen']} yen charged" if c["settled"] else f"up to {c['reserved_yen']} yen reserved"))
|
|
101
|
+
out["artifacts"] = [a["name"] for a in j["artifacts"]][:50]
|
|
102
|
+
return out
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _payment_block(exc: ClientError) -> Optional[dict[str, Any]]:
|
|
106
|
+
"""残高不足(402)なら、決済ページのリンクと次の手順を返す。"""
|
|
107
|
+
p = exc.payment()
|
|
108
|
+
if p is None:
|
|
109
|
+
return None
|
|
110
|
+
p["next"] = ("show checkout_url to the user and ask them to pay in a browser (Stripe; the rest stays as credit); "
|
|
111
|
+
"after they paid, call billing() to confirm the balance, then submit the same job again")
|
|
112
|
+
return p
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _resolve_scene(c: Client, scene_path: str, scene_id: str, scene_script: str = "") -> str:
|
|
116
|
+
if scene_id:
|
|
117
|
+
return scene_id
|
|
118
|
+
if scene_script.strip():
|
|
119
|
+
up = c.upload_text("scene.py", scene_script)
|
|
120
|
+
_log(f"uploaded inline script -> {up['scene_id']} ({up['size']} bytes)")
|
|
121
|
+
return up["scene_id"]
|
|
122
|
+
if not scene_path:
|
|
123
|
+
raise ValueError("pass scene_path (a .blend or a bpy .py script), scene_script (bpy code as text), "
|
|
124
|
+
"or scene_id from a previous call")
|
|
125
|
+
up = c.upload(scene_path)
|
|
126
|
+
_log(f"uploaded {up['name']} -> {up['scene_id']} ({up['size']} bytes)")
|
|
127
|
+
return up["scene_id"]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@mcp.tool()
|
|
131
|
+
def scene_info(scene_path: str = "", scene_id: str = "", scene_script: str = "") -> str:
|
|
132
|
+
"""Read a Blender scene WITHOUT rendering: cameras (and which is active), frame range and fps,
|
|
133
|
+
resolution, engine, objects by type, lights, materials, whether it is animated, and any linked
|
|
134
|
+
files that are missing (textures etc. that would render pink). Takes a few seconds, no GPU.
|
|
135
|
+
|
|
136
|
+
Give the scene as scene_path (.blend or .py file), scene_script (bpy Python code as text, no
|
|
137
|
+
file needed), or scene_id from a previous call. Call this first for a .blend you did not write
|
|
138
|
+
yourself, or after writing a bpy script to check what it produced. Use the camera names and
|
|
139
|
+
frame range in render_preview / render_final. Returns scene_id to reuse in the next calls.
|
|
140
|
+
"""
|
|
141
|
+
c = _client()
|
|
142
|
+
try:
|
|
143
|
+
sid = _resolve_scene(c, scene_path, scene_id, scene_script)
|
|
144
|
+
j = c.submit(sid, kind="info")
|
|
145
|
+
if j["status"] != "done":
|
|
146
|
+
j = c.wait(j["job_id"], timeout=INFO_WAIT_S)
|
|
147
|
+
except (ClientError, ValueError, FileNotFoundError) as exc:
|
|
148
|
+
return _j({"ok": False, "error": str(exc)})
|
|
149
|
+
if j["status"] != "done":
|
|
150
|
+
b = _brief(j)
|
|
151
|
+
b["hint"] = "still queued (a GPU worker may be busy); call scene_info again in a moment" \
|
|
152
|
+
if j["status"] in ("queued", "running") else "fix the scene and try again"
|
|
153
|
+
return _j(b)
|
|
154
|
+
info = dict(j.get("info") or {})
|
|
155
|
+
info["scene_id"] = j["scene_id"]
|
|
156
|
+
if info.get("engine") and info["engine"] != "CYCLES":
|
|
157
|
+
info["engine_note"] = "JANCTION renders with CYCLES; the scene's engine setting is not used"
|
|
158
|
+
if j.get("warnings"):
|
|
159
|
+
info["blender_warnings"] = j["warnings"]
|
|
160
|
+
if info.get("missing_files"):
|
|
161
|
+
info["note"] = "missing_files will render pink/black; pack them into the .blend (File > External Data > Pack) or fix the paths"
|
|
162
|
+
return _j(info)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@mcp.tool(structured_output=False)
|
|
166
|
+
def render_preview(
|
|
167
|
+
scene_path: str = "",
|
|
168
|
+
scene_id: str = "",
|
|
169
|
+
scene_script: str = "",
|
|
170
|
+
frames: str = "",
|
|
171
|
+
camera: str = "",
|
|
172
|
+
width: int = 1280,
|
|
173
|
+
height: int = 720,
|
|
174
|
+
samples: int = 16,
|
|
175
|
+
out_dir: str = "",
|
|
176
|
+
) -> list[Any]:
|
|
177
|
+
"""Render a fast, cheap preview of a Blender scene on a JANCTION GPU and show the image.
|
|
178
|
+
|
|
179
|
+
Use this when the user is making 3DCG with Blender and wants to see how it looks, but has no
|
|
180
|
+
GPU or local rendering is slow. Give the scene as scene_path (a .blend file OR a bpy Python
|
|
181
|
+
script), scene_script (bpy code as text; no file and no local Blender needed), or scene_id
|
|
182
|
+
from a previous call. frames: '' = frame 1;
|
|
183
|
+
'12' = one frame; '1,8,16,24' or '1-24' = up to 4 frames tiled in ONE image (2x2, each tile
|
|
184
|
+
labeled with its frame number) so you can judge camera motion and animation. Same GPU cost as
|
|
185
|
+
one 720p frame. Quality is deliberately low (up to 1280x720, few samples, denoised).
|
|
186
|
+
Look at the returned image, fix the scene, preview again; when it looks right, ask the user and
|
|
187
|
+
call render_final. Returns scene_id (reuse it without re-uploading), job_id, saved PNG paths,
|
|
188
|
+
Blender warnings (e.g. missing textures), GPU seconds, and the expiry time (24h after last use).
|
|
189
|
+
"""
|
|
190
|
+
c = _client()
|
|
191
|
+
try:
|
|
192
|
+
frame_list = parse_frames(frames)
|
|
193
|
+
sid = _resolve_scene(c, scene_path, scene_id, scene_script)
|
|
194
|
+
j = c.submit(sid, kind="preview", frames=frame_list, camera=camera or None,
|
|
195
|
+
width=width, height=height, samples=samples)
|
|
196
|
+
_log(f"preview job {j['job_id']} submitted frames={frame_list}")
|
|
197
|
+
j = c.wait(j["job_id"], timeout=PREVIEW_WAIT_S)
|
|
198
|
+
except ClientError as exc:
|
|
199
|
+
block = _payment_block(exc) or _quota_block(exc)
|
|
200
|
+
return [_j(block if block else {"ok": False, "error": str(exc)})]
|
|
201
|
+
except (ValueError, FileNotFoundError) as exc:
|
|
202
|
+
return [_j({"ok": False, "error": str(exc)})]
|
|
203
|
+
brief = _brief(j)
|
|
204
|
+
if j["status"] != "done":
|
|
205
|
+
brief["hint"] = ("still running: call render_status(job_id) and then render_download"
|
|
206
|
+
if j["status"] in ("queued", "running")
|
|
207
|
+
else "fix the scene and try again")
|
|
208
|
+
return [_j(brief)]
|
|
209
|
+
out = Path(out_dir) if out_dir else _out_root() / j["job_id"]
|
|
210
|
+
paths = c.download(j["job_id"], out)
|
|
211
|
+
show = next((p for p in paths if p.name == "sheet.png"), None) or next((p for p in paths if p.suffix == ".png"), None)
|
|
212
|
+
brief["files"] = [str(p) for p in paths]
|
|
213
|
+
brief["next"] = ("look at the image; if the scene needs changes, edit it and call render_preview again; "
|
|
214
|
+
"if it looks right, ask the user before calling render_final")
|
|
215
|
+
result: list[Any] = [_j(brief)]
|
|
216
|
+
if show is not None:
|
|
217
|
+
result.append(Image(data=show.read_bytes(), format="png"))
|
|
218
|
+
return result
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@mcp.tool()
|
|
222
|
+
def render_final(
|
|
223
|
+
scene_path: str = "",
|
|
224
|
+
scene_id: str = "",
|
|
225
|
+
scene_script: str = "",
|
|
226
|
+
frame_start: int = 1,
|
|
227
|
+
frame_end: int = 1,
|
|
228
|
+
width: int = 1920,
|
|
229
|
+
height: int = 1080,
|
|
230
|
+
samples: int = 128,
|
|
231
|
+
fps: int = 24,
|
|
232
|
+
output: str = "auto",
|
|
233
|
+
camera: str = "",
|
|
234
|
+
) -> str:
|
|
235
|
+
"""Render the final frames (or a video) of a Blender scene on JANCTION GPUs.
|
|
236
|
+
|
|
237
|
+
Call this after the user approved a preview. Pass scene_id from render_preview (or scene_path /
|
|
238
|
+
scene_script to upload a new .blend / bpy script). frame_start..frame_end are inclusive; several frames are
|
|
239
|
+
split across GPUs and joined into output.mp4 (output='mp4', fps), a single frame gives a PNG
|
|
240
|
+
(output='png'; 'auto' picks). Returns job_id and a time estimate right away; the render runs
|
|
241
|
+
in the background: poll with render_status, then fetch with render_download. Inputs and
|
|
242
|
+
results are deleted 24 hours after last use, so download them.
|
|
243
|
+
"""
|
|
244
|
+
c = _client()
|
|
245
|
+
try:
|
|
246
|
+
sid = _resolve_scene(c, scene_path, scene_id, scene_script)
|
|
247
|
+
j = c.submit(sid, kind="final", frame_start=frame_start, frame_end=frame_end,
|
|
248
|
+
width=width, height=height, samples=samples, fps=fps, output=output,
|
|
249
|
+
camera=camera or None)
|
|
250
|
+
except ClientError as exc:
|
|
251
|
+
block = _payment_block(exc) or _quota_block(exc)
|
|
252
|
+
return _j(block if block else {"ok": False, "error": str(exc)})
|
|
253
|
+
except (ValueError, FileNotFoundError) as exc:
|
|
254
|
+
return _j({"ok": False, "error": str(exc)})
|
|
255
|
+
brief = _brief(j)
|
|
256
|
+
brief["estimate"] = j.get("estimate")
|
|
257
|
+
brief["next"] = "render_status(job_id) until status is done, then render_download(job_id)"
|
|
258
|
+
return _j(brief)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@mcp.tool()
|
|
262
|
+
def render_status(job_id: str) -> str:
|
|
263
|
+
"""Check a JANCTION render job: status (queued/running/done/failed/canceled), frames done,
|
|
264
|
+
how many chunks are queued ahead, artifacts ready, Blender warnings, and the error plus log
|
|
265
|
+
tail if it failed. Use render_download once status is done."""
|
|
266
|
+
try:
|
|
267
|
+
return _j(_brief(_client().job(job_id)))
|
|
268
|
+
except ClientError as exc:
|
|
269
|
+
return _j({"ok": False, "error": str(exc)})
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@mcp.tool()
|
|
273
|
+
def render_download(job_id: str, out_dir: str = "", wait_seconds: int = 0) -> str:
|
|
274
|
+
"""Download a JANCTION render job's files (PNG frames, sheet.png and/or output.mp4) to out_dir
|
|
275
|
+
(default ~/janction-render/<job_id>). wait_seconds > 0 first waits up to that long for the job
|
|
276
|
+
to finish. Returns the local file paths."""
|
|
277
|
+
c = _client()
|
|
278
|
+
try:
|
|
279
|
+
j = c.wait(job_id, timeout=float(wait_seconds)) if wait_seconds > 0 else c.job(job_id)
|
|
280
|
+
if j["status"] != "done" and not j["artifacts"]:
|
|
281
|
+
b = _brief(j)
|
|
282
|
+
b["hint"] = "nothing to download yet"
|
|
283
|
+
return _j(b)
|
|
284
|
+
out = Path(out_dir) if out_dir else _out_root() / job_id
|
|
285
|
+
paths = c.download(job_id, out)
|
|
286
|
+
except ClientError as exc:
|
|
287
|
+
return _j({"ok": False, "error": str(exc)})
|
|
288
|
+
b = _brief(j)
|
|
289
|
+
b["files"] = [str(p) for p in paths]
|
|
290
|
+
if j["status"] != "done":
|
|
291
|
+
b["hint"] = "job is not finished; these are the frames ready so far"
|
|
292
|
+
return _j(b)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@mcp.tool()
|
|
296
|
+
def render_cancel(job_id: str) -> str:
|
|
297
|
+
"""Cancel a queued or running JANCTION render job. GPU time after the cancel is not used."""
|
|
298
|
+
try:
|
|
299
|
+
return _j(_brief(_client().cancel(job_id)))
|
|
300
|
+
except ClientError as exc:
|
|
301
|
+
return _j({"ok": False, "error": str(exc)})
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
@mcp.tool()
|
|
305
|
+
def billing(topup_yen: int = 0) -> str:
|
|
306
|
+
"""Check the account's quota or credit. During the free beta it returns today's GPU-time usage,
|
|
307
|
+
the daily quota and when it resets (no charges). Once paid plans start: with topup_yen = 0 it
|
|
308
|
+
confirms any payment the user just made and returns the balance (yen), the price per GPU second
|
|
309
|
+
and free previews left; with topup_yen > 0 (minimum 500) it returns a Stripe checkout URL to show
|
|
310
|
+
to the user. Renders are charged by GPU seconds and only for frames that actually rendered."""
|
|
311
|
+
c = _client()
|
|
312
|
+
try:
|
|
313
|
+
if topup_yen > 0:
|
|
314
|
+
co = c.checkout(topup_yen)
|
|
315
|
+
return _j({"checkout_url": co["checkout_url"], "amount_yen": co["amount_yen"],
|
|
316
|
+
"next": "the user pays at checkout_url in a browser; then call billing() to confirm"})
|
|
317
|
+
me = c.me()
|
|
318
|
+
synced: dict[str, Any] = {}
|
|
319
|
+
if me["billing"]["enabled"]:
|
|
320
|
+
synced = c.billing_sync()
|
|
321
|
+
me = c.me()
|
|
322
|
+
if me.get("quota"):
|
|
323
|
+
q = me["quota"]
|
|
324
|
+
return _j({"mode": "free_beta", "note": "no charges during the free beta; daily GPU-time quota per key",
|
|
325
|
+
"gpu_seconds_used_today": q["gpu_seconds_used_today"], "gpu_seconds_per_day": q["gpu_seconds_per_day"],
|
|
326
|
+
"resets_at": q["resets_at"], "max_frames_per_job": q["max_frames_per_job"],
|
|
327
|
+
"max_pixels": q["max_pixels"]})
|
|
328
|
+
return _j({"balance_yen": me["balance_yen"], "free_previews_left": me["free_previews_left"],
|
|
329
|
+
"yen_per_gpu_second": me["billing"]["yen_per_gpu_second"],
|
|
330
|
+
"min_topup_yen": me["billing"]["min_topup_yen"], "billing_enabled": me["billing"]["enabled"],
|
|
331
|
+
"just_credited": synced.get("credited", []),
|
|
332
|
+
"pending_checkout_url": me["billing"]["pending_checkout_url"],
|
|
333
|
+
"charged_yen_total": me["charged_yen_total"]})
|
|
334
|
+
except ClientError as exc:
|
|
335
|
+
return _j({"ok": False, "error": str(exc)})
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
@mcp.tool()
|
|
339
|
+
def render_info() -> str:
|
|
340
|
+
"""Show the JANCTION Render connection: server URL, whether GPU workers are online, queue
|
|
341
|
+
length, and this key's usage. Call this if renders seem stuck or before the first render."""
|
|
342
|
+
c = _client()
|
|
343
|
+
try:
|
|
344
|
+
health = c.health()
|
|
345
|
+
me = c.me()
|
|
346
|
+
except (ClientError, Exception) as exc: # noqa: BLE001
|
|
347
|
+
return _j({"ok": False, "server": c.server, "error": str(exc)})
|
|
348
|
+
return _j({"ok": True, "server": c.server, "workers": health["workers"], "queue": health["queue"],
|
|
349
|
+
"key": me, "downloads_go_to": str(_out_root())})
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def main() -> None:
|
|
353
|
+
_log(f"stdio, server={Client().server}")
|
|
354
|
+
mcp.run()
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
if __name__ == "__main__":
|
|
358
|
+
main()
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: janction-render
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Render Blender scenes on JANCTION GPUs straight from terminal AI agents (Claude Code, Codex): MCP server + CLI
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Dist: requests>=2.31
|
|
9
|
+
Requires-Dist: mcp>=1.10
|
|
10
|
+
Provides-Extra: mcp
|
|
11
|
+
Requires-Dist: mcp>=1.10; extra == "mcp"
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# JANCTION Render
|
|
17
|
+
|
|
18
|
+
Render Blender scenes on JANCTION GPUs straight from a terminal AI agent (Claude Code, Codex, or any MCP client).
|
|
19
|
+
Use it when you are building 3DCG with Blender and have no GPU, or rendering locally is slow.
|
|
20
|
+
|
|
21
|
+
- Input: a `.blend` file, or a **bpy Python script that builds the scene** (no local Blender needed).
|
|
22
|
+
- `scene_info` reads the scene without rendering (cameras, frame range, missing files).
|
|
23
|
+
- `render_preview` returns 1-4 low-cost frames tiled in one image within seconds, so the agent can look, fix, and repeat.
|
|
24
|
+
- `render_final` renders the frames on GPUs and joins them into an MP4.
|
|
25
|
+
- Free beta: no charges. Each key gets 10 GPU-minutes per day; a final render is up to 240 frames at 1080p.
|
|
26
|
+
Paid plans (per GPU second, prepaid credit) will be announced on the service page before they start.
|
|
27
|
+
- Inputs and results are deleted 24 hours after last use and are never used for training.
|
|
28
|
+
|
|
29
|
+
## Status
|
|
30
|
+
|
|
31
|
+
Free beta. The public endpoint is `https://render.janction.jp` (opening; check `/v1/health`). Until it answers, you can
|
|
32
|
+
self-host the server and worker from the internal package (see Self-hosting) or wait for the announcement in `/llms.txt`.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
Until the package is on PyPI, install straight from this repository:
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
# Claude Code
|
|
40
|
+
claude mcp add janction-render -e JANCTION_RENDER_SERVER=https://render.janction.jp -- uvx --from git+https://github.com/KJdayo/janction-render janction-render-mcp
|
|
41
|
+
|
|
42
|
+
# or with pipx
|
|
43
|
+
pipx install git+https://github.com/KJdayo/janction-render
|
|
44
|
+
claude mcp add janction-render -e JANCTION_RENDER_SERVER=https://render.janction.jp -- janction-render-mcp
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Codex: add to `~/.codex/config.toml`
|
|
48
|
+
|
|
49
|
+
```toml
|
|
50
|
+
[mcp_servers.janction-render]
|
|
51
|
+
command = "uvx"
|
|
52
|
+
args = ["--from", "git+https://github.com/KJdayo/janction-render", "janction-render-mcp"]
|
|
53
|
+
env = { JANCTION_RENDER_SERVER = "https://render.janction.jp" }
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
A temporary API key is issued automatically on first use and cached in `~/.janction-render.json`; set
|
|
57
|
+
`JANCTION_RENDER_API_KEY` to pin one (quota and, later, credit belong to the key).
|
|
58
|
+
|
|
59
|
+
Then, in Claude Code:
|
|
60
|
+
|
|
61
|
+
> Build a small street scene in Blender with a camera fly-through and render a preview with janction-render.
|
|
62
|
+
|
|
63
|
+
## Tools
|
|
64
|
+
|
|
65
|
+
| tool | what |
|
|
66
|
+
|---|---|
|
|
67
|
+
| `scene_info(scene_path | scene_script | scene_id)` | cameras, frame range, fps, resolution, objects, lights, missing files. No render. |
|
|
68
|
+
| `render_preview(..., frames="1-24")` | up to 4 frames (720p budget) tiled with frame labels; returns the image inline |
|
|
69
|
+
| `render_final(scene_id, frame_start, frame_end, width, height, samples, fps, output)` | PNG or MP4; returns job_id + estimate |
|
|
70
|
+
| `render_status(job_id)` / `render_download(job_id)` / `render_cancel(job_id)` | progress, files, cancel |
|
|
71
|
+
| `billing(topup_yen=0)` | free-beta quota (used today, daily limit, reset time); later balance and top-up link |
|
|
72
|
+
| `render_info()` | server, workers online, queue |
|
|
73
|
+
|
|
74
|
+
CLI: `janction-render inspect|preview|render|status|download|cancel|jobs|balance|topup|info`.
|
|
75
|
+
|
|
76
|
+
## Writing a scene script
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
import bpy, math
|
|
80
|
+
for ob in list(bpy.data.objects):
|
|
81
|
+
bpy.data.objects.remove(ob, do_unlink=True)
|
|
82
|
+
scene = bpy.context.scene
|
|
83
|
+
scene.frame_start, scene.frame_end = 1, 24
|
|
84
|
+
bpy.ops.mesh.primitive_monkey_add(location=(0, 0, 1))
|
|
85
|
+
cam = bpy.data.objects.new("Camera", bpy.data.cameras.new("Camera"))
|
|
86
|
+
scene.collection.objects.link(cam); scene.camera = cam
|
|
87
|
+
cam.location = (6, -6, 4); cam.rotation_euler = (math.radians(60), 0, math.radians(45))
|
|
88
|
+
sun = bpy.data.objects.new("Sun", bpy.data.lights.new("Sun", "SUN"))
|
|
89
|
+
scene.collection.objects.link(sun)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The service sets engine (Cycles), resolution, samples and denoising; your script sets the scene, camera and frame range.
|
|
93
|
+
Scripts run in an isolated container with no network. See `samples/cube_scene.py`.
|
|
94
|
+
|
|
95
|
+
## HTTP API
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
POST /v1/keys -> {api_key} (header X-API-Key afterwards)
|
|
99
|
+
POST /v1/files multipart "file" (.blend|.py) -> {scene_id}
|
|
100
|
+
POST /v1/jobs {scene_id, kind: info|preview|final, frames|frame_start/frame_end, width, height, samples, camera, fps, output}
|
|
101
|
+
GET /v1/jobs/{id} status, progress, artifacts[], cost, warnings, info DELETE /v1/jobs/{id} cancel
|
|
102
|
+
GET /v1/jobs/{id}/artifacts/{name} PNG / MP4
|
|
103
|
+
POST /v1/billing/checkout {amount_yen} -> {checkout_url} POST /v1/billing/sync GET /v1/ledger GET /v1/me
|
|
104
|
+
GET /llms.txt /terms /privacy /tokushoho
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
During the free beta a `429 quota_exceeded` response carries `resets_at`; a `400 beta_limit` means the job is too big
|
|
108
|
+
(split it). Once paid plans start, a `402 payment_required` response carries `checkout_url`.
|
|
109
|
+
|
|
110
|
+
## Self-hosting
|
|
111
|
+
|
|
112
|
+
The server (FastAPI + SQLite) and the worker (Blender in disposable Docker containers, `--network none --cap-drop ALL`)
|
|
113
|
+
live in the internal repository and are not part of this package yet. This repository holds the client side: MCP server, CLI,
|
|
114
|
+
HTTP client and samples.
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
MIT (see LICENSE). Operated by JasmyLab Inc.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
janction_render/__init__.py,sha256=N4SQoQqiiHbuExcbHjmi7L4yV2niuyv7MQacbN7-_ys,122
|
|
2
|
+
janction_render/cli.py,sha256=sCeuRQozqZMzDvjVscsUeF0gItaa3oBo7877I5AakZU,10810
|
|
3
|
+
janction_render/client.py,sha256=jrK2oLqfJnrLK33CP2I53ThCgVSXjSbBEElTsIDtcSQ,10631
|
|
4
|
+
janction_render/mcp_server.py,sha256=mW87tMVAALOi8zywoBTVluvuyic0ZO4kUhqYicYQHkQ,16108
|
|
5
|
+
janction_render-0.1.0.dist-info/licenses/LICENSE,sha256=CA2hQaY4n7sg6CJ3w976wCrsw0ZpYiFgQI9xUTnmNZs,1106
|
|
6
|
+
janction_render-0.1.0.dist-info/METADATA,sha256=JrqYYUepxZc1wT6ARPNeNcedrpDRBelM-hnqtZmdBsc,5429
|
|
7
|
+
janction_render-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
8
|
+
janction_render-0.1.0.dist-info/entry_points.txt,sha256=wFzcUIGRcRe7pvLb0D2UkRYLZSkSxC5FvUMn1xS_Q9Y,115
|
|
9
|
+
janction_render-0.1.0.dist-info/top_level.txt,sha256=y2LPa34ZfAuOVZzTJHbqRC0daOQ9X4p6iCao60MW940,16
|
|
10
|
+
janction_render-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 JasmyLab Inc. (ジャスミーラボ株式会社)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
janction_render
|