janction-render 0.3.3__tar.gz → 0.4.0__tar.gz
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-0.3.3 → janction_render-0.4.0}/PKG-INFO +5 -4
- {janction_render-0.3.3 → janction_render-0.4.0}/README.md +4 -3
- {janction_render-0.3.3 → janction_render-0.4.0}/janction_render/__init__.py +1 -1
- {janction_render-0.3.3 → janction_render-0.4.0}/janction_render/cli.py +6 -3
- {janction_render-0.3.3 → janction_render-0.4.0}/janction_render/client.py +4 -4
- janction_render-0.4.0/janction_render/mcp_app.html +157 -0
- {janction_render-0.3.3 → janction_render-0.4.0}/janction_render/mcp_server.py +40 -8
- {janction_render-0.3.3 → janction_render-0.4.0}/janction_render.egg-info/PKG-INFO +5 -4
- {janction_render-0.3.3 → janction_render-0.4.0}/janction_render.egg-info/SOURCES.txt +1 -0
- {janction_render-0.3.3 → janction_render-0.4.0}/pyproject.toml +4 -1
- {janction_render-0.3.3 → janction_render-0.4.0}/LICENSE +0 -0
- {janction_render-0.3.3 → janction_render-0.4.0}/janction_render/brief.py +0 -0
- {janction_render-0.3.3 → janction_render-0.4.0}/janction_render.egg-info/dependency_links.txt +0 -0
- {janction_render-0.3.3 → janction_render-0.4.0}/janction_render.egg-info/entry_points.txt +0 -0
- {janction_render-0.3.3 → janction_render-0.4.0}/janction_render.egg-info/requires.txt +0 -0
- {janction_render-0.3.3 → janction_render-0.4.0}/janction_render.egg-info/top_level.txt +0 -0
- {janction_render-0.3.3 → janction_render-0.4.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: janction-render
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Cloud GPU render farm for Blender, for AI agents: MCP server (remote + stdio) and CLI. Render Blender without a GPU from Claude, ChatGPT, Claude Code, Codex, Cursor.
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -35,6 +35,7 @@ without a GPU, or when rendering locally is slow.
|
|
|
35
35
|
- While a job renders, `render_status` counts finished frames inside the running chunk, so the ETA updates every few seconds.
|
|
36
36
|
- In hosts that support MCP Apps (Claude web and desktop, among others), previews, progress and download links also appear
|
|
37
37
|
as an interactive panel in the chat: the image, a progress bar, "Open MP4", and preset buttons after an `environment="compare"` preview.
|
|
38
|
+
Both the remote connector and the stdio package ship the panel (`janction_render/mcp_app.html`); set `JR_MCP_APPS=0` to turn it off.
|
|
38
39
|
- `scene_info` reads the scene without rendering (cameras, frame range, missing files).
|
|
39
40
|
- `render_preview` returns 1-4 low-cost frames tiled in one image within seconds, so the agent can look, fix, and repeat.
|
|
40
41
|
- `render_estimate` says how long a render will take ("about 3 minutes") and whether it fits today's free quota.
|
|
@@ -113,7 +114,7 @@ Then, in Claude Code:
|
|
|
113
114
|
| `scene_info(scene_script | scene_url | scene_path | scene_id, assets?)` | cameras, frame range, fps, resolution, objects, lights, missing files. No render. |
|
|
114
115
|
| `render_preview(..., frames="1-24", environment?, blender?)` | up to 4 frames (720p budget) tiled with frame labels; returns the image inline |
|
|
115
116
|
| `render_estimate(scene_id, frame_start, frame_end, width, height, samples)` | GPU seconds, queue wait, "about N minutes", fits today's free quota? No GPU time used |
|
|
116
|
-
| `render_final(scene_id, frame_start, frame_end, width, height, samples, fps, output, environment?, blender?)` |
|
|
117
|
+
| `render_final(scene_id, frame_start, frame_end, width, height, samples, fps, output, environment?, blender?, engine?)` | frames (png / exr) or video (mp4 / webm / prores); returns job_id + estimate |
|
|
117
118
|
| `render_status(job_id)` | progress and ETA (`eta.human`); `render_download(job_id, only="mp4" / "frames" / "all")` files or links; `render_cancel(job_id)` |
|
|
118
119
|
| `billing()` | free-beta quota (used today, daily limit, reset time); later balance and top-up link |
|
|
119
120
|
| `render_info()` | workers online or gated (GPU lent to another workload), queue, expected wait, supported inputs, environment presets, Blender versions |
|
|
@@ -142,7 +143,7 @@ sun = bpy.data.objects.new("Sun", bpy.data.lights.new("Sun", "SUN"))
|
|
|
142
143
|
scene.collection.objects.link(sun)
|
|
143
144
|
```
|
|
144
145
|
|
|
145
|
-
The service sets engine (Cycles), resolution, samples and denoising; your script sets the scene, camera and frame range.
|
|
146
|
+
The service sets the engine (Cycles by default; `engine="eevee"` for cheaper animations: about half the per-frame cost after ~7 s of shader compilation per job), resolution, samples and denoising; your script sets the scene, camera and frame range. In Blender 5.0 materials always use nodes: set the Principled BSDF Base Color, not `diffuse_color`.
|
|
146
147
|
Scripts run in an isolated container with no network. See `samples/cube_scene.py`.
|
|
147
148
|
|
|
148
149
|
## HTTP API
|
|
@@ -152,7 +153,7 @@ POST /v1/keys -> {api_key} (header X-A
|
|
|
152
153
|
POST /v1/files multipart "file" (.blend|.py|.glb|.fbx|.usd|.obj|...) -> {scene_id}
|
|
153
154
|
POST /v1/files/{id}/assets multipart "files" textures, glTF .bin ... GET /v1/files/lookup?sha256= reuse an upload
|
|
154
155
|
POST /v1/estimate {kind, frames|frame_start/frame_end, width, height, samples, scene_id?} -> seconds, wall_seconds, human, quota
|
|
155
|
-
POST /v1/jobs {scene_id, kind: info|preview|final, frames|frame_start/frame_end, width, height, samples, camera, fps, output,
|
|
156
|
+
POST /v1/jobs {scene_id, kind: info|preview|final, frames|frame_start/frame_end, width, height, samples, camera, fps, output (png|exr|mp4|webm|prores), engine (cycles|eevee),
|
|
156
157
|
environment, environment_strength, environment_visible, blender, orbit, orbit_frames, orbit_elevation}
|
|
157
158
|
GET /v1/jobs/{id} status, progress, eta, artifacts[], cost, warnings, info DELETE /v1/jobs/{id} cancel
|
|
158
159
|
GET /v1/jobs/{id}/artifacts/{name} PNG / MP4
|
|
@@ -18,6 +18,7 @@ without a GPU, or when rendering locally is slow.
|
|
|
18
18
|
- While a job renders, `render_status` counts finished frames inside the running chunk, so the ETA updates every few seconds.
|
|
19
19
|
- In hosts that support MCP Apps (Claude web and desktop, among others), previews, progress and download links also appear
|
|
20
20
|
as an interactive panel in the chat: the image, a progress bar, "Open MP4", and preset buttons after an `environment="compare"` preview.
|
|
21
|
+
Both the remote connector and the stdio package ship the panel (`janction_render/mcp_app.html`); set `JR_MCP_APPS=0` to turn it off.
|
|
21
22
|
- `scene_info` reads the scene without rendering (cameras, frame range, missing files).
|
|
22
23
|
- `render_preview` returns 1-4 low-cost frames tiled in one image within seconds, so the agent can look, fix, and repeat.
|
|
23
24
|
- `render_estimate` says how long a render will take ("about 3 minutes") and whether it fits today's free quota.
|
|
@@ -96,7 +97,7 @@ Then, in Claude Code:
|
|
|
96
97
|
| `scene_info(scene_script | scene_url | scene_path | scene_id, assets?)` | cameras, frame range, fps, resolution, objects, lights, missing files. No render. |
|
|
97
98
|
| `render_preview(..., frames="1-24", environment?, blender?)` | up to 4 frames (720p budget) tiled with frame labels; returns the image inline |
|
|
98
99
|
| `render_estimate(scene_id, frame_start, frame_end, width, height, samples)` | GPU seconds, queue wait, "about N minutes", fits today's free quota? No GPU time used |
|
|
99
|
-
| `render_final(scene_id, frame_start, frame_end, width, height, samples, fps, output, environment?, blender?)` |
|
|
100
|
+
| `render_final(scene_id, frame_start, frame_end, width, height, samples, fps, output, environment?, blender?, engine?)` | frames (png / exr) or video (mp4 / webm / prores); returns job_id + estimate |
|
|
100
101
|
| `render_status(job_id)` | progress and ETA (`eta.human`); `render_download(job_id, only="mp4" / "frames" / "all")` files or links; `render_cancel(job_id)` |
|
|
101
102
|
| `billing()` | free-beta quota (used today, daily limit, reset time); later balance and top-up link |
|
|
102
103
|
| `render_info()` | workers online or gated (GPU lent to another workload), queue, expected wait, supported inputs, environment presets, Blender versions |
|
|
@@ -125,7 +126,7 @@ sun = bpy.data.objects.new("Sun", bpy.data.lights.new("Sun", "SUN"))
|
|
|
125
126
|
scene.collection.objects.link(sun)
|
|
126
127
|
```
|
|
127
128
|
|
|
128
|
-
The service sets engine (Cycles), resolution, samples and denoising; your script sets the scene, camera and frame range.
|
|
129
|
+
The service sets the engine (Cycles by default; `engine="eevee"` for cheaper animations: about half the per-frame cost after ~7 s of shader compilation per job), resolution, samples and denoising; your script sets the scene, camera and frame range. In Blender 5.0 materials always use nodes: set the Principled BSDF Base Color, not `diffuse_color`.
|
|
129
130
|
Scripts run in an isolated container with no network. See `samples/cube_scene.py`.
|
|
130
131
|
|
|
131
132
|
## HTTP API
|
|
@@ -135,7 +136,7 @@ POST /v1/keys -> {api_key} (header X-A
|
|
|
135
136
|
POST /v1/files multipart "file" (.blend|.py|.glb|.fbx|.usd|.obj|...) -> {scene_id}
|
|
136
137
|
POST /v1/files/{id}/assets multipart "files" textures, glTF .bin ... GET /v1/files/lookup?sha256= reuse an upload
|
|
137
138
|
POST /v1/estimate {kind, frames|frame_start/frame_end, width, height, samples, scene_id?} -> seconds, wall_seconds, human, quota
|
|
138
|
-
POST /v1/jobs {scene_id, kind: info|preview|final, frames|frame_start/frame_end, width, height, samples, camera, fps, output,
|
|
139
|
+
POST /v1/jobs {scene_id, kind: info|preview|final, frames|frame_start/frame_end, width, height, samples, camera, fps, output (png|exr|mp4|webm|prores), engine (cycles|eevee),
|
|
139
140
|
environment, environment_strength, environment_visible, blender, orbit, orbit_frames, orbit_elevation}
|
|
140
141
|
GET /v1/jobs/{id} status, progress, eta, artifacts[], cost, warnings, info DELETE /v1/jobs/{id} cancel
|
|
141
142
|
GET /v1/jobs/{id}/artifacts/{name} PNG / MP4
|
|
@@ -79,7 +79,7 @@ def cmd_preview(c: Client, args: argparse.Namespace) -> int:
|
|
|
79
79
|
w, h = args.size
|
|
80
80
|
frames = parse_frames(args.frames, default=args.frame)
|
|
81
81
|
j = c.submit(sid, kind="preview", frames=frames, camera=args.camera, width=w, height=h,
|
|
82
|
-
samples=args.samples)
|
|
82
|
+
samples=args.samples, engine=args.engine)
|
|
83
83
|
print(f"job {j['job_id']} queued: frames {frames} (estimate ~{j['estimate']['seconds']}s)", file=sys.stderr)
|
|
84
84
|
j = c.wait(j["job_id"], timeout=args.timeout, on_progress=_progress)
|
|
85
85
|
if j["status"] != "done":
|
|
@@ -98,7 +98,7 @@ def cmd_render(c: Client, args: argparse.Namespace) -> int:
|
|
|
98
98
|
w, h = args.size
|
|
99
99
|
fs, fe = args.frames
|
|
100
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)
|
|
101
|
+
samples=args.samples, fps=args.fps, output=args.output, camera=args.camera, engine=args.engine)
|
|
102
102
|
print(f"job {j['job_id']} queued: {fe - fs + 1} frames in {j['progress']['chunks_total']} chunks, "
|
|
103
103
|
f"estimate ~{j['estimate']['seconds']}s", file=sys.stderr)
|
|
104
104
|
if not args.wait:
|
|
@@ -187,6 +187,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
187
187
|
s.add_argument("--camera", default=None)
|
|
188
188
|
s.add_argument("--size", type=_size, default=(1280, 720))
|
|
189
189
|
s.add_argument("--samples", type=int, default=16)
|
|
190
|
+
s.add_argument("--engine", choices=["cycles", "eevee"], default=None, help="cycles (default) or eevee (cheaper drafts)")
|
|
190
191
|
s.add_argument("--out", default=None)
|
|
191
192
|
s.add_argument("--timeout", type=float, default=300)
|
|
192
193
|
s.set_defaults(fn=cmd_preview)
|
|
@@ -197,8 +198,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
197
198
|
s.add_argument("--frames", type=_frames, required=True, help="e.g. 1-240 or 12")
|
|
198
199
|
s.add_argument("--size", type=_size, default=(1920, 1080))
|
|
199
200
|
s.add_argument("--samples", type=int, default=128)
|
|
201
|
+
s.add_argument("--engine", choices=["cycles", "eevee"], default=None, help="cycles (default) or eevee (cheaper drafts)")
|
|
200
202
|
s.add_argument("--fps", type=int, default=24)
|
|
201
|
-
s.add_argument("--output", choices=["auto", "png", "mp4"], default="auto"
|
|
203
|
+
s.add_argument("--output", choices=["auto", "png", "exr", "mp4", "webm", "prores"], default="auto",
|
|
204
|
+
help="png/exr = frames, mp4/webm/prores = video (auto: mp4 for a range, png for one frame)")
|
|
202
205
|
s.add_argument("--camera", default=None)
|
|
203
206
|
s.add_argument("--out", default=None)
|
|
204
207
|
s.add_argument("--wait", action="store_true", help="wait until done and download")
|
|
@@ -19,7 +19,7 @@ CACHE = Path.home() / ".janction-render.json"
|
|
|
19
19
|
|
|
20
20
|
# 送れるシーン: .blend、bpy スクリプト、こちらの Blender が読み込める 3D ファイル
|
|
21
21
|
SCENE_SUFFIXES = (".blend", ".py", ".fbx", ".glb", ".gltf", ".obj", ".stl", ".ply", ".usd", ".usda", ".usdc", ".usdz", ".abc")
|
|
22
|
-
ONLY_CHOICES = ("all", "mp4", "frames", "sheet", "output")
|
|
22
|
+
ONLY_CHOICES = ("all", "mp4", "frames", "sheet", "output", "video")
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
def select_artifacts(artifacts: list[dict[str, Any]], only: Optional[str] = None) -> list[dict[str, Any]]:
|
|
@@ -27,14 +27,14 @@ def select_artifacts(artifacts: list[dict[str, Any]], only: Optional[str] = None
|
|
|
27
27
|
o = (only or "all").strip().lower()
|
|
28
28
|
if o in ("", "all"):
|
|
29
29
|
return list(artifacts)
|
|
30
|
-
if o
|
|
31
|
-
return [a for a in artifacts if a["name"].endswith(".mp4")]
|
|
30
|
+
if o in ("mp4", "video"):
|
|
31
|
+
return [a for a in artifacts if a["name"].endswith((".mp4", ".webm", ".mov"))]
|
|
32
32
|
if o == "frames":
|
|
33
33
|
return [a for a in artifacts if a["name"].startswith("frame_")]
|
|
34
34
|
if o == "sheet":
|
|
35
35
|
return [a for a in artifacts if a["name"] == "sheet.png"]
|
|
36
36
|
if o == "output":
|
|
37
|
-
picked = [a for a in artifacts if a["name"] in ("output.mp4", "sheet.png")]
|
|
37
|
+
picked = [a for a in artifacts if a["name"] in ("output.mp4", "output.webm", "output.mov", "sheet.png")]
|
|
38
38
|
return picked or [a for a in artifacts if a["name"].startswith("frame_")][:1]
|
|
39
39
|
raise ValueError("only must be one of: " + ", ".join(ONLY_CHOICES))
|
|
40
40
|
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<meta name="color-scheme" content="light dark">
|
|
7
|
+
<title>JANCTION Render</title>
|
|
8
|
+
<style>
|
|
9
|
+
:root { color-scheme: light dark; --fg: #1b1b1f; --muted: #5f6368; --line: #d9dbe0; --bg: #ffffff; --accent: #1f3a8a; --ok: #1a7f37; --warn: #b26a00; --bad: #b3261e; }
|
|
10
|
+
@media (prefers-color-scheme: dark) { :root { --fg: #e8eaed; --muted: #9aa0a6; --line: #3c4043; --bg: #1b1b1f; --accent: #8ab4f8; --ok: #6ddb8a; --warn: #f2b84b; --bad: #f28b82; } }
|
|
11
|
+
html, body { margin: 0; padding: 0; background: transparent; color: var(--fg); font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
12
|
+
.card { padding: 12px 14px; }
|
|
13
|
+
.head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
|
14
|
+
.kind { font-weight: 600; }
|
|
15
|
+
.pill { font-size: 12px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--line); color: var(--muted); }
|
|
16
|
+
.pill.done { color: var(--ok); border-color: var(--ok); }
|
|
17
|
+
.pill.run { color: var(--accent); border-color: var(--accent); }
|
|
18
|
+
.pill.bad { color: var(--bad); border-color: var(--bad); }
|
|
19
|
+
.muted { color: var(--muted); font-size: 12px; }
|
|
20
|
+
img.sheet { display: block; max-width: 100%; height: auto; border-radius: 8px; margin: 10px 0 6px; border: 1px solid var(--line); }
|
|
21
|
+
video { display: block; max-width: 100%; border-radius: 8px; margin: 10px 0 6px; border: 1px solid var(--line); }
|
|
22
|
+
.bar { height: 8px; background: var(--line); border-radius: 4px; overflow: hidden; margin: 8px 0 4px; }
|
|
23
|
+
.bar > i { display: block; height: 100%; width: 0; background: var(--accent); transition: width .4s; }
|
|
24
|
+
.row { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
|
|
25
|
+
button { font: inherit; font-size: 13px; padding: 6px 12px; border-radius: 6px; border: 1px solid var(--accent); background: transparent; color: var(--accent); cursor: pointer; }
|
|
26
|
+
button.primary { background: var(--accent); color: #fff; }
|
|
27
|
+
button:disabled { opacity: .5; cursor: default; }
|
|
28
|
+
details { margin-top: 8px; } summary { cursor: pointer; color: var(--muted); font-size: 12px; }
|
|
29
|
+
ul.warn { margin: 4px 0 0 18px; padding: 0; font-size: 12px; color: var(--warn); }
|
|
30
|
+
.err { color: var(--bad); white-space: pre-wrap; font-size: 12px; }
|
|
31
|
+
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-top: 8px; }
|
|
32
|
+
</style>
|
|
33
|
+
</head>
|
|
34
|
+
<body>
|
|
35
|
+
<div class="card" id="root"><span class="muted">JANCTION Render</span></div>
|
|
36
|
+
<script type="module">
|
|
37
|
+
import { App } from "https://unpkg.com/@modelcontextprotocol/ext-apps@0.4.0/app-with-deps";
|
|
38
|
+
|
|
39
|
+
const root = document.getElementById("root");
|
|
40
|
+
const app = new App({ name: "JANCTION Render", version: "1.0.0" });
|
|
41
|
+
let state = { job: null, image: null, toolName: "", pollTimer: null, polls: 0 };
|
|
42
|
+
const PRESETS = ["studio", "sunset", "overcast", "night"];
|
|
43
|
+
|
|
44
|
+
function parseResult(result) {
|
|
45
|
+
const content = result?.content || [];
|
|
46
|
+
const text = content.find(c => c.type === "text")?.text || "";
|
|
47
|
+
let job = null;
|
|
48
|
+
try { job = JSON.parse(text); } catch (e) { job = { raw: text }; }
|
|
49
|
+
const img = content.find(c => c.type === "image");
|
|
50
|
+
return { job, image: img ? `data:${img.mimeType || "image/png"};base64,${img.data}` : null };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function esc(s) { return String(s ?? "").replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); }
|
|
54
|
+
|
|
55
|
+
function progressOf(job) {
|
|
56
|
+
const m = /(\d+)\/(\d+) frames/.exec(job?.progress || "");
|
|
57
|
+
if (!m) return null;
|
|
58
|
+
const done = +m[1], total = +m[2];
|
|
59
|
+
return { done, total, pct: total ? Math.round(100 * done / total) : 0 };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isCompare(job) {
|
|
63
|
+
const w = job?.warnings || [];
|
|
64
|
+
return w.some(x => String(x).startsWith("compare:"));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function render() {
|
|
68
|
+
const job = state.job;
|
|
69
|
+
if (!job) { root.innerHTML = '<span class="muted">JANCTION Render</span>'; return; }
|
|
70
|
+
if (job.ok === false || job.error && !job.status) {
|
|
71
|
+
root.innerHTML = `<div class="head"><span class="kind">JANCTION Render</span><span class="pill bad">error</span></div><div class="err">${esc(job.error || job.detail || "")}\n${esc(job.detail || "")}</div>`;
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const status = job.status || "";
|
|
75
|
+
const pillClass = status === "done" ? "done" : (status === "failed" || status === "canceled") ? "bad" : "run";
|
|
76
|
+
const kind = job.kind === "preview" ? "Preview" : job.kind === "final" ? "Final render" : job.kind === "info" ? "Scene info" : "Render";
|
|
77
|
+
let html = `<div class="head"><span class="kind">${esc(kind)}</span><span class="pill ${pillClass}">${esc(status || "ready")}</span>`;
|
|
78
|
+
if (job.frames) html += `<span class="muted">frames ${esc(job.frames)}</span>`;
|
|
79
|
+
if (job.gpu_seconds) html += `<span class="muted">${esc(job.gpu_seconds)} GPU s</span>`;
|
|
80
|
+
if (job.device) html += `<span class="muted">${esc(job.device)}</span>`;
|
|
81
|
+
html += `</div>`;
|
|
82
|
+
const eta = job.eta?.human || job.estimate?.human;
|
|
83
|
+
if (status === "queued" || status === "running") {
|
|
84
|
+
const p = progressOf(job);
|
|
85
|
+
if (p) html += `<div class="bar"><i style="width:${p.pct}%"></i></div><div class="muted">${p.done}/${p.total} frames${eta ? " · " + esc(eta) : ""}</div>`;
|
|
86
|
+
else if (eta) html += `<div class="muted">${esc(eta)}</div>`;
|
|
87
|
+
} else if (eta && status === "done") {
|
|
88
|
+
html += `<div class="muted">${esc(eta)}</div>`;
|
|
89
|
+
}
|
|
90
|
+
if (state.image) html += `<img class="sheet" src="${state.image}" alt="render preview">`;
|
|
91
|
+
const out = job.output_url;
|
|
92
|
+
const links = job.links || {};
|
|
93
|
+
if (status === "done" && out && /\.(mp4|webm|mov)(\?|$)/.test(out)) {
|
|
94
|
+
html += `<video controls playsinline src="${esc(out)}"></video>`;
|
|
95
|
+
}
|
|
96
|
+
if (status === "done" && isCompare(job)) {
|
|
97
|
+
html += `<div class="muted">Pick the lighting for the final render:</div><div class="grid">` +
|
|
98
|
+
PRESETS.map(p => `<button data-preset="${p}">Use ${p}</button>`).join("") + `</div>`;
|
|
99
|
+
}
|
|
100
|
+
html += `<div class="row">`;
|
|
101
|
+
if (status === "queued" || status === "running") html += `<button data-act="refresh">Refresh</button>`;
|
|
102
|
+
if (status === "done" && out) html += `<button class="primary" data-open="${esc(out)}">Open ${/\.(mp4|webm|mov)/.test(out) ? "video" : "image"}</button>`;
|
|
103
|
+
const frameLinks = Object.entries(links).filter(([n]) => n.startsWith("frame_"));
|
|
104
|
+
if (status === "done" && frameLinks.length) html += `<button data-open="${esc(frameLinks[0][1])}">Open first frame</button>`;
|
|
105
|
+
if (status === "done" && job.kind === "preview") html += `<button data-say="This preview looks right. Please ask me about frames, resolution and samples, then render the final.">Looks good, go final</button><button data-say="Please adjust the scene and preview again.">Needs changes</button>`;
|
|
106
|
+
html += `</div>`;
|
|
107
|
+
const warns = (job.warnings || []).filter(w => !String(w).startsWith("compare:"));
|
|
108
|
+
if (warns.length) html += `<details><summary>${warns.length} note${warns.length > 1 ? "s" : ""} from Blender</summary><ul class="warn">${warns.map(w => `<li>${esc(w)}</li>`).join("")}</ul></details>`;
|
|
109
|
+
if (job.expires_at) html += `<div class="muted">files kept until ${esc(job.expires_at)}</div>`;
|
|
110
|
+
root.innerHTML = html;
|
|
111
|
+
root.querySelectorAll("button[data-open]").forEach(b => b.addEventListener("click", () => app.openLink({ url: b.dataset.open })));
|
|
112
|
+
root.querySelectorAll("button[data-say]").forEach(b => b.addEventListener("click", () => app.sendMessage({ role: "user", content: { type: "text", text: b.dataset.say } })));
|
|
113
|
+
root.querySelectorAll("button[data-preset]").forEach(b => b.addEventListener("click", () => app.sendMessage({ role: "user", content: { type: "text", text: `Use the ${b.dataset.preset} lighting preset (environment="${b.dataset.preset}") for the final render. Ask me about frames, resolution and samples first.` } })));
|
|
114
|
+
root.querySelector("button[data-act=refresh]")?.addEventListener("click", refresh);
|
|
115
|
+
schedulePoll();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function refresh() {
|
|
119
|
+
const id = state.job?.job_id;
|
|
120
|
+
if (!id) return;
|
|
121
|
+
try {
|
|
122
|
+
const r = await app.callServerTool({ name: "render_status", arguments: { job_id: id } });
|
|
123
|
+
const parsed = parseResult(r);
|
|
124
|
+
if (parsed.job && parsed.job.job_id) state.job = parsed.job;
|
|
125
|
+
if (parsed.image) state.image = parsed.image;
|
|
126
|
+
if (state.job.status === "done" && !state.image && state.job.kind === "preview") {
|
|
127
|
+
const d = await app.callServerTool({ name: "render_download", arguments: { job_id: id } });
|
|
128
|
+
const p2 = parseResult(d);
|
|
129
|
+
if (p2.image) state.image = p2.image;
|
|
130
|
+
if (p2.job && p2.job.job_id) state.job = p2.job;
|
|
131
|
+
}
|
|
132
|
+
render();
|
|
133
|
+
} catch (e) { console.error(e); }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function schedulePoll() {
|
|
137
|
+
if (state.pollTimer) { clearTimeout(state.pollTimer); state.pollTimer = null; }
|
|
138
|
+
const s = state.job?.status;
|
|
139
|
+
if ((s === "queued" || s === "running") && state.polls < 120) {
|
|
140
|
+
state.polls += 1;
|
|
141
|
+
state.pollTimer = setTimeout(refresh, 5000);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
app.ontoolresult = (result) => {
|
|
146
|
+
const parsed = parseResult(result);
|
|
147
|
+
state.job = parsed.job; state.image = parsed.image; state.polls = 0;
|
|
148
|
+
render();
|
|
149
|
+
};
|
|
150
|
+
app.ontoolinput = () => { root.innerHTML = '<span class="muted">Rendering on JANCTION…</span>'; };
|
|
151
|
+
|
|
152
|
+
await app.connect();
|
|
153
|
+
const ctx = app.getHostContext?.();
|
|
154
|
+
state.toolName = ctx?.toolInfo?.tool?.name || "";
|
|
155
|
+
</script>
|
|
156
|
+
</body>
|
|
157
|
+
</html>
|
|
@@ -41,7 +41,8 @@ mcp = MCPServer(
|
|
|
41
41
|
"in one image; look at it, fix the scene, repeat) -> ask the user 'is this OK?' -> render_estimate (tell the "
|
|
42
42
|
"user how long it takes and whether it fits today's free quota) -> render_final (frames or MP4) -> "
|
|
43
43
|
"render_status / render_download. Always tell the user the time estimate (estimate.human / eta.human). "
|
|
44
|
-
"Blender 5.0 with Cycles on GPU (blender='5.2' selects Blender 5.2 when the worker has it)
|
|
44
|
+
"Blender 5.0 with Cycles on GPU (blender='5.2' selects Blender 5.2 when the worker has it); engine='eevee' switches to EEVEE "
|
|
45
|
+
"(about half the per-frame cost on animations of a few dozen frames or more; each job pays ~7 s of shader compilation, so previews stay on Cycles); output='png'|'exr' returns frames, 'mp4'|'webm'|'prores' a video. Blender 5.0 API notes for scripts: materials always use nodes, so set a color on the Principled BSDF (mat.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value = (r, g, b, 1)); mat.diffuse_color only affects the viewport. Animate with obj.keyframe_insert(); obj.animation_data.action.fcurves no longer exists (set bpy.context.preferences.edit.keyframe_new_interpolation_type='LINEAR' before inserting keyframes for constant-speed motion); do not set Material.use_nodes; leave resolution, samples and denoising to the service. Inputs: a .blend, a bpy "
|
|
45
46
|
"script, or a 3D file (FBX, glTF/GLB, USD, OBJ, STL, PLY, Alembic) that is imported into an empty scene; images "
|
|
46
47
|
"and other assets can be sent with the scene (assets=[...]). environment='studio'|'sunset'|'overcast'|'night' "
|
|
47
48
|
"lights the scene with a bundled HDRI for a good-looking first render. Inputs and results are deleted 24 hours "
|
|
@@ -55,6 +56,23 @@ ORBIT_DOC = (" orbit=True: turntable; an orbit camera circles the scene once ove
|
|
|
55
56
|
"elevation orbit_elevation deg, default 18). Flat floors/walls are ignored when framing; orbit_target = an "
|
|
56
57
|
"object name or 'x,y,z' centres on that, orbit_distance scales the distance (1.0). A preview without frames "
|
|
57
58
|
"shows 0/90/180/270 deg; a final without frame_end renders the whole turn as an MP4. Best for imported models.")
|
|
59
|
+
# ---- MCP Apps(Claude Desktop など対応ホストに、絵と進み具合のパネルを出す。対応していないホストは無視する)
|
|
60
|
+
UI_URI = "ui://janction-render/app.html"
|
|
61
|
+
UI_MIME = "text/html;profile=mcp-app"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def ui_enabled() -> bool:
|
|
65
|
+
return (os.environ.get("JR_MCP_APPS") or "1").strip().lower() not in ("0", "false", "no", "off")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _ui_meta() -> Optional[dict[str, Any]]:
|
|
69
|
+
return {"ui": {"resourceUri": UI_URI}, "ui/resourceUri": UI_URI} if ui_enabled() else None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def ui_html() -> str:
|
|
73
|
+
return (Path(__file__).resolve().parent / "mcp_app.html").read_text(encoding="utf-8")
|
|
74
|
+
|
|
75
|
+
|
|
58
76
|
ENV_DOC = ("environment: '' keeps the scene's own world; 'studio' | 'sunset' | 'overcast' | 'night' replaces it with a "
|
|
59
77
|
"bundled HDRI (good first render for scenes without lighting work); 'compare' (render_preview only) renders the "
|
|
60
78
|
"first frame under all four presets in one 2x2 image with labels, so you can pick one; environment_strength scales it (1.0); "
|
|
@@ -155,8 +173,11 @@ def _resolve_scene(c: Client, scene_path: str, scene_id: str, scene_script: str
|
|
|
155
173
|
return up["scene_id"]
|
|
156
174
|
|
|
157
175
|
|
|
158
|
-
def _job_params(environment: str, environment_strength: float, environment_visible: bool, blender: str
|
|
176
|
+
def _job_params(environment: str, environment_strength: float, environment_visible: bool, blender: str,
|
|
177
|
+
engine: str = "") -> dict[str, Any]:
|
|
159
178
|
out: dict[str, Any] = {}
|
|
179
|
+
if engine:
|
|
180
|
+
out["engine"] = engine
|
|
160
181
|
if environment:
|
|
161
182
|
out.update({"environment": environment, "environment_strength": environment_strength,
|
|
162
183
|
"environment_visible": environment_visible})
|
|
@@ -218,7 +239,7 @@ def scene_info(scene_path: str = "", scene_id: str = "", scene_script: str = "",
|
|
|
218
239
|
return _j(_attach_upload_notes(info))
|
|
219
240
|
|
|
220
241
|
|
|
221
|
-
@mcp.tool(structured_output=False)
|
|
242
|
+
@mcp.tool(structured_output=False, meta=_ui_meta())
|
|
222
243
|
def render_preview(
|
|
223
244
|
scene_path: str = "",
|
|
224
245
|
scene_id: str = "",
|
|
@@ -239,6 +260,7 @@ def render_preview(
|
|
|
239
260
|
orbit_elevation: float = 18.0,
|
|
240
261
|
orbit_target: str = "",
|
|
241
262
|
orbit_distance: float = 1.0,
|
|
263
|
+
engine: str = "",
|
|
242
264
|
) -> list[Any]:
|
|
243
265
|
"""Render a fast, cheap preview of a Blender scene on a JANCTION GPU and show the image.
|
|
244
266
|
|
|
@@ -259,7 +281,7 @@ def render_preview(
|
|
|
259
281
|
sid = _resolve_scene(c, scene_path, scene_id, scene_script, assets)
|
|
260
282
|
j = c.submit(sid, kind="preview", frames=frame_list, camera=camera or None,
|
|
261
283
|
width=width, height=height, samples=samples,
|
|
262
|
-
**_job_params(environment, environment_strength, environment_visible, blender),
|
|
284
|
+
**_job_params(environment, environment_strength, environment_visible, blender, engine=engine),
|
|
263
285
|
**({"orbit": True, "orbit_frames": orbit_frames, "orbit_elevation": orbit_elevation,
|
|
264
286
|
"orbit_target": orbit_target, "orbit_distance": orbit_distance} if orbit else {}))
|
|
265
287
|
_log(f"preview job {j['job_id']} submitted frames={frame_list}")
|
|
@@ -319,7 +341,7 @@ def render_estimate(scene_id: str = "", kind: str = "final", frame_start: int =
|
|
|
319
341
|
return _j(out)
|
|
320
342
|
|
|
321
343
|
|
|
322
|
-
@mcp.tool()
|
|
344
|
+
@mcp.tool(meta=_ui_meta())
|
|
323
345
|
def render_final(
|
|
324
346
|
scene_path: str = "",
|
|
325
347
|
scene_id: str = "",
|
|
@@ -342,6 +364,7 @@ def render_final(
|
|
|
342
364
|
orbit_elevation: float = 18.0,
|
|
343
365
|
orbit_target: str = "",
|
|
344
366
|
orbit_distance: float = 1.0,
|
|
367
|
+
engine: str = "",
|
|
345
368
|
) -> str:
|
|
346
369
|
"""Render the final frames (or a video) of a Blender scene on JANCTION GPUs.
|
|
347
370
|
|
|
@@ -364,7 +387,7 @@ def render_final(
|
|
|
364
387
|
j = c.submit(sid, kind="final", frame_start=frame_start, frame_end=frame_end,
|
|
365
388
|
width=width, height=height, samples=samples, fps=fps, output=output,
|
|
366
389
|
camera=camera or None,
|
|
367
|
-
**_job_params(environment, environment_strength, environment_visible, blender), **extra)
|
|
390
|
+
**_job_params(environment, environment_strength, environment_visible, blender, engine=engine), **extra)
|
|
368
391
|
except ClientError as exc:
|
|
369
392
|
block = _payment_block(exc) or _quota_block(exc)
|
|
370
393
|
return _j(block if block else {"ok": False, "error": str(exc)})
|
|
@@ -377,7 +400,7 @@ def render_final(
|
|
|
377
400
|
return _j(_attach_upload_notes(brief))
|
|
378
401
|
|
|
379
402
|
|
|
380
|
-
@mcp.tool()
|
|
403
|
+
@mcp.tool(meta=_ui_meta())
|
|
381
404
|
def render_status(job_id: str) -> str:
|
|
382
405
|
"""Check a JANCTION render job: status (queued/running/done/failed/canceled), frames done,
|
|
383
406
|
how many chunks are queued ahead, the ETA (eta.human = remaining time including queue wait),
|
|
@@ -393,7 +416,7 @@ def render_status(job_id: str) -> str:
|
|
|
393
416
|
return _j(b)
|
|
394
417
|
|
|
395
418
|
|
|
396
|
-
@mcp.tool()
|
|
419
|
+
@mcp.tool(meta=_ui_meta())
|
|
397
420
|
def render_download(job_id: str, out_dir: str = "", wait_seconds: int = 0, only: str = "all") -> str:
|
|
398
421
|
"""Download a JANCTION render job's files to out_dir (default ~/janction-render/<job_id>).
|
|
399
422
|
only: 'all' (default: every PNG frame plus output.mp4 / sheet.png), 'mp4' (just the video), 'frames'
|
|
@@ -483,6 +506,15 @@ def render_info() -> str:
|
|
|
483
506
|
"remote_mcp": f"{c.server}/mcp (Streamable HTTP; for Claude.ai, ChatGPT, Cursor: add this URL as a connector)"})
|
|
484
507
|
|
|
485
508
|
|
|
509
|
+
if ui_enabled():
|
|
510
|
+
@mcp.resource(UI_URI, name="JANCTION Render app", title="JANCTION Render", mime_type=UI_MIME,
|
|
511
|
+
description="Interactive view for JANCTION Render results: preview image, progress, ETA, links, preset picker.",
|
|
512
|
+
meta={"ui": {"csp": {"resourceDomains": ["https://unpkg.com", "https://render.janction.jp"], "connectDomains": []},
|
|
513
|
+
"prefersBorder": True}})
|
|
514
|
+
def janction_render_app() -> str:
|
|
515
|
+
return ui_html()
|
|
516
|
+
|
|
517
|
+
|
|
486
518
|
def main() -> None:
|
|
487
519
|
_log(f"stdio, server={Client().server}")
|
|
488
520
|
mcp.run()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: janction-render
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Cloud GPU render farm for Blender, for AI agents: MCP server (remote + stdio) and CLI. Render Blender without a GPU from Claude, ChatGPT, Claude Code, Codex, Cursor.
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -35,6 +35,7 @@ without a GPU, or when rendering locally is slow.
|
|
|
35
35
|
- While a job renders, `render_status` counts finished frames inside the running chunk, so the ETA updates every few seconds.
|
|
36
36
|
- In hosts that support MCP Apps (Claude web and desktop, among others), previews, progress and download links also appear
|
|
37
37
|
as an interactive panel in the chat: the image, a progress bar, "Open MP4", and preset buttons after an `environment="compare"` preview.
|
|
38
|
+
Both the remote connector and the stdio package ship the panel (`janction_render/mcp_app.html`); set `JR_MCP_APPS=0` to turn it off.
|
|
38
39
|
- `scene_info` reads the scene without rendering (cameras, frame range, missing files).
|
|
39
40
|
- `render_preview` returns 1-4 low-cost frames tiled in one image within seconds, so the agent can look, fix, and repeat.
|
|
40
41
|
- `render_estimate` says how long a render will take ("about 3 minutes") and whether it fits today's free quota.
|
|
@@ -113,7 +114,7 @@ Then, in Claude Code:
|
|
|
113
114
|
| `scene_info(scene_script | scene_url | scene_path | scene_id, assets?)` | cameras, frame range, fps, resolution, objects, lights, missing files. No render. |
|
|
114
115
|
| `render_preview(..., frames="1-24", environment?, blender?)` | up to 4 frames (720p budget) tiled with frame labels; returns the image inline |
|
|
115
116
|
| `render_estimate(scene_id, frame_start, frame_end, width, height, samples)` | GPU seconds, queue wait, "about N minutes", fits today's free quota? No GPU time used |
|
|
116
|
-
| `render_final(scene_id, frame_start, frame_end, width, height, samples, fps, output, environment?, blender?)` |
|
|
117
|
+
| `render_final(scene_id, frame_start, frame_end, width, height, samples, fps, output, environment?, blender?, engine?)` | frames (png / exr) or video (mp4 / webm / prores); returns job_id + estimate |
|
|
117
118
|
| `render_status(job_id)` | progress and ETA (`eta.human`); `render_download(job_id, only="mp4" / "frames" / "all")` files or links; `render_cancel(job_id)` |
|
|
118
119
|
| `billing()` | free-beta quota (used today, daily limit, reset time); later balance and top-up link |
|
|
119
120
|
| `render_info()` | workers online or gated (GPU lent to another workload), queue, expected wait, supported inputs, environment presets, Blender versions |
|
|
@@ -142,7 +143,7 @@ sun = bpy.data.objects.new("Sun", bpy.data.lights.new("Sun", "SUN"))
|
|
|
142
143
|
scene.collection.objects.link(sun)
|
|
143
144
|
```
|
|
144
145
|
|
|
145
|
-
The service sets engine (Cycles), resolution, samples and denoising; your script sets the scene, camera and frame range.
|
|
146
|
+
The service sets the engine (Cycles by default; `engine="eevee"` for cheaper animations: about half the per-frame cost after ~7 s of shader compilation per job), resolution, samples and denoising; your script sets the scene, camera and frame range. In Blender 5.0 materials always use nodes: set the Principled BSDF Base Color, not `diffuse_color`.
|
|
146
147
|
Scripts run in an isolated container with no network. See `samples/cube_scene.py`.
|
|
147
148
|
|
|
148
149
|
## HTTP API
|
|
@@ -152,7 +153,7 @@ POST /v1/keys -> {api_key} (header X-A
|
|
|
152
153
|
POST /v1/files multipart "file" (.blend|.py|.glb|.fbx|.usd|.obj|...) -> {scene_id}
|
|
153
154
|
POST /v1/files/{id}/assets multipart "files" textures, glTF .bin ... GET /v1/files/lookup?sha256= reuse an upload
|
|
154
155
|
POST /v1/estimate {kind, frames|frame_start/frame_end, width, height, samples, scene_id?} -> seconds, wall_seconds, human, quota
|
|
155
|
-
POST /v1/jobs {scene_id, kind: info|preview|final, frames|frame_start/frame_end, width, height, samples, camera, fps, output,
|
|
156
|
+
POST /v1/jobs {scene_id, kind: info|preview|final, frames|frame_start/frame_end, width, height, samples, camera, fps, output (png|exr|mp4|webm|prores), engine (cycles|eevee),
|
|
156
157
|
environment, environment_strength, environment_visible, blender, orbit, orbit_frames, orbit_elevation}
|
|
157
158
|
GET /v1/jobs/{id} status, progress, eta, artifacts[], cost, warnings, info DELETE /v1/jobs/{id} cancel
|
|
158
159
|
GET /v1/jobs/{id}/artifacts/{name} PNG / MP4
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "janction-render"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "Cloud GPU render farm for Blender, for AI agents: MCP server (remote + stdio) and CLI. Render Blender without a GPU from Claude, ChatGPT, Claude Code, Codex, Cursor."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -21,3 +21,6 @@ janction-render-mcp = "janction_render.mcp_server:main"
|
|
|
21
21
|
|
|
22
22
|
[tool.setuptools.packages.find]
|
|
23
23
|
include = ["janction_render*"]
|
|
24
|
+
|
|
25
|
+
[tool.setuptools.package-data]
|
|
26
|
+
janction_render = ["mcp_app.html"]
|
|
File without changes
|
|
File without changes
|
{janction_render-0.3.3 → janction_render-0.4.0}/janction_render.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|