janction-render 0.2.1__tar.gz → 0.3.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.2.1 → janction_render-0.3.0}/PKG-INFO +32 -13
- {janction_render-0.2.1 → janction_render-0.3.0}/README.md +29 -12
- {janction_render-0.2.1 → janction_render-0.3.0}/janction_render/__init__.py +1 -1
- {janction_render-0.2.1 → janction_render-0.3.0}/janction_render/brief.py +26 -1
- {janction_render-0.2.1 → janction_render-0.3.0}/janction_render/client.py +139 -11
- {janction_render-0.2.1 → janction_render-0.3.0}/janction_render/mcp_server.py +115 -38
- {janction_render-0.2.1 → janction_render-0.3.0}/janction_render.egg-info/PKG-INFO +32 -13
- {janction_render-0.2.1 → janction_render-0.3.0}/janction_render.egg-info/requires.txt +3 -0
- {janction_render-0.2.1 → janction_render-0.3.0}/pyproject.toml +2 -1
- {janction_render-0.2.1 → janction_render-0.3.0}/LICENSE +0 -0
- {janction_render-0.2.1 → janction_render-0.3.0}/janction_render/cli.py +0 -0
- {janction_render-0.2.1 → janction_render-0.3.0}/janction_render.egg-info/SOURCES.txt +0 -0
- {janction_render-0.2.1 → janction_render-0.3.0}/janction_render.egg-info/dependency_links.txt +0 -0
- {janction_render-0.2.1 → janction_render-0.3.0}/janction_render.egg-info/entry_points.txt +0 -0
- {janction_render-0.2.1 → janction_render-0.3.0}/janction_render.egg-info/top_level.txt +0 -0
- {janction_render-0.2.1 → janction_render-0.3.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.3.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
|
|
@@ -9,17 +9,23 @@ Requires-Dist: requests>=2.31
|
|
|
9
9
|
Requires-Dist: mcp>=1.10
|
|
10
10
|
Provides-Extra: mcp
|
|
11
11
|
Requires-Dist: mcp>=1.10; extra == "mcp"
|
|
12
|
+
Provides-Extra: blend
|
|
13
|
+
Requires-Dist: blender-asset-tracer>=1.20; extra == "blend"
|
|
12
14
|
Provides-Extra: dev
|
|
13
15
|
Requires-Dist: pytest>=8; extra == "dev"
|
|
14
16
|
Dynamic: license-file
|
|
15
17
|
|
|
16
18
|
# JANCTION Render
|
|
17
19
|
|
|
20
|
+
[](https://glama.ai/mcp/servers/JasmyLab-JANCTION/janction-render)
|
|
21
|
+
|
|
18
22
|
A cloud GPU render farm for Blender, built for AI agents. Render Blender scenes on JANCTION GPUs from **Claude, ChatGPT,
|
|
19
23
|
Claude Code, Codex, Cursor or any MCP client**: an MCP server (remote and stdio) plus a CLI. Use it to render Blender
|
|
20
24
|
without a GPU, or when rendering locally is slow.
|
|
21
25
|
|
|
22
|
-
- Input: a `.blend` file,
|
|
26
|
+
- Input: a `.blend` file (textures travel with it), a **bpy Python script that builds the scene** (no local Blender
|
|
27
|
+
needed), or a **3D file** (glTF/GLB, FBX, USD, OBJ, STL, PLY, Alembic) that is imported into an empty scene.
|
|
28
|
+
- `environment="studio" | "sunset" | "overcast" | "night"` lights a scene with a bundled HDRI (CC0) for a good first render.
|
|
23
29
|
- `scene_info` reads the scene without rendering (cameras, frame range, missing files).
|
|
24
30
|
- `render_preview` returns 1-4 low-cost frames tiled in one image within seconds, so the agent can look, fix, and repeat.
|
|
25
31
|
- `render_estimate` says how long a render will take ("about 3 minutes") and whether it fits today's free quota.
|
|
@@ -30,7 +36,12 @@ without a GPU, or when rendering locally is slow.
|
|
|
30
36
|
|
|
31
37
|
## Status
|
|
32
38
|
|
|
33
|
-
Free beta. Service page: `https://render.janction.jp` (`/v1/health`, `/llms.txt`). Blender 5.0, Cycles on GPU
|
|
39
|
+
Free beta. Service page: `https://render.janction.jp` (`/v1/health`, `/llms.txt`, `/faq`). Blender 5.0, Cycles on GPU
|
|
40
|
+
(`blender="5.2"` selects Blender 5.2 when the worker has it; `render_info` lists what is available).
|
|
41
|
+
Guides: [render farm for agents](https://render.janction.jp/blender-render-farm) · [without a GPU](https://render.janction.jp/render-blender-without-gpu) ·
|
|
42
|
+
[Claude](https://render.janction.jp/claude-blender) · [ChatGPT](https://render.janction.jp/chatgpt-blender) · [Claude Code](https://render.janction.jp/claude-code-blender) ·
|
|
43
|
+
[Codex](https://render.janction.jp/codex-blender) · [bpy scripts](https://render.janction.jp/bpy-script-cloud-gpu) · [API](https://render.janction.jp/blender-render-api) ·
|
|
44
|
+
[comparison](https://render.janction.jp/blender-render-farm-api-comparison) · 日本語は `/ja/`
|
|
34
45
|
|
|
35
46
|
## Connect (remote MCP, nothing to install)
|
|
36
47
|
|
|
@@ -45,8 +56,9 @@ also works as `Authorization: Bearer jr_...`.
|
|
|
45
56
|
| Claude Code | `claude mcp add --transport http janction-render https://render.janction.jp/mcp`, then `/mcp` to authenticate |
|
|
46
57
|
| Cursor, Windsurf, other MCP clients | Streamable HTTP at the URL above (OAuth, or a Bearer API key header) |
|
|
47
58
|
|
|
48
|
-
Remote tools take `scene_script` (bpy code as text), `scene_url` (an https link to a `.blend` or
|
|
49
|
-
results come back as an inline image plus download links
|
|
59
|
+
Remote tools take `scene_script` (bpy code as text), `scene_url` (an https link to a `.blend`, `.py` or a 3D file) or
|
|
60
|
+
`scene_id`, plus `asset_urls` for textures or glTF `.bin` files; results come back as an inline image plus download links
|
|
61
|
+
that need no key and work for about 24 hours (`render_download(only="mp4")` for just the video).
|
|
50
62
|
|
|
51
63
|
Claude Code plugin (the remote connector plus a skill with the workflow):
|
|
52
64
|
|
|
@@ -89,13 +101,18 @@ Then, in Claude Code:
|
|
|
89
101
|
|
|
90
102
|
| tool | what |
|
|
91
103
|
|---|---|
|
|
92
|
-
| `scene_info(scene_script | scene_url | scene_path | scene_id)` | cameras, frame range, fps, resolution, objects, lights, missing files. No render. |
|
|
93
|
-
| `render_preview(..., frames="1-24")` | up to 4 frames (720p budget) tiled with frame labels; returns the image inline |
|
|
104
|
+
| `scene_info(scene_script | scene_url | scene_path | scene_id, assets?)` | cameras, frame range, fps, resolution, objects, lights, missing files. No render. |
|
|
105
|
+
| `render_preview(..., frames="1-24", environment?, blender?)` | up to 4 frames (720p budget) tiled with frame labels; returns the image inline |
|
|
94
106
|
| `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 |
|
|
95
|
-
| `render_final(scene_id, frame_start, frame_end, width, height, samples, fps, output)` | PNG or MP4; returns job_id + estimate |
|
|
96
|
-
| `render_status(job_id)` | progress and ETA (`eta.human`); `render_download(job_id)` files or links; `render_cancel(job_id)` |
|
|
107
|
+
| `render_final(scene_id, frame_start, frame_end, width, height, samples, fps, output, environment?, blender?)` | PNG or MP4; returns job_id + estimate |
|
|
108
|
+
| `render_status(job_id)` | progress and ETA (`eta.human`); `render_download(job_id, only="mp4" / "frames" / "all")` files or links; `render_cancel(job_id)` |
|
|
97
109
|
| `billing()` | free-beta quota (used today, daily limit, reset time); later balance and top-up link |
|
|
98
|
-
| `render_info()` | workers online, queue
|
|
110
|
+
| `render_info()` | workers online, queue, expected wait, supported inputs, environment presets, Blender versions |
|
|
111
|
+
|
|
112
|
+
Options on `render_preview` / `render_final`: `environment` (`studio`, `sunset`, `overcast`, `night`; `environment_strength`,
|
|
113
|
+
`environment_visible=False` for a flat grey backdrop with HDRI lighting), `blender` (`"5.2"`), and `assets` (stdio: local files
|
|
114
|
+
sent with the scene, found in the script through `os.environ["JR_ASSETS_DIR"]`; a `.blend`'s external files are collected
|
|
115
|
+
automatically with `pip install janction-render[blend]`) or `asset_urls` (remote).
|
|
99
116
|
|
|
100
117
|
CLI: `janction-render inspect|preview|render|status|download|cancel|jobs|balance|topup|info`.
|
|
101
118
|
|
|
@@ -122,15 +139,17 @@ Scripts run in an isolated container with no network. See `samples/cube_scene.py
|
|
|
122
139
|
|
|
123
140
|
```
|
|
124
141
|
POST /v1/keys -> {api_key} (header X-API-Key afterwards)
|
|
125
|
-
POST /v1/files multipart "file" (.blend|.py) -> {scene_id}
|
|
142
|
+
POST /v1/files multipart "file" (.blend|.py|.glb|.fbx|.usd|.obj|...) -> {scene_id}
|
|
143
|
+
POST /v1/files/{id}/assets multipart "files" textures, glTF .bin ... GET /v1/files/lookup?sha256= reuse an upload
|
|
126
144
|
POST /v1/estimate {kind, frames|frame_start/frame_end, width, height, samples, scene_id?} -> seconds, wall_seconds, human, quota
|
|
127
|
-
POST /v1/jobs {scene_id, kind: info|preview|final, frames|frame_start/frame_end, width, height, samples, camera, fps, output
|
|
145
|
+
POST /v1/jobs {scene_id, kind: info|preview|final, frames|frame_start/frame_end, width, height, samples, camera, fps, output,
|
|
146
|
+
environment, environment_strength, environment_visible, blender}
|
|
128
147
|
GET /v1/jobs/{id} status, progress, eta, artifacts[], cost, warnings, info DELETE /v1/jobs/{id} cancel
|
|
129
148
|
GET /v1/jobs/{id}/artifacts/{name} PNG / MP4
|
|
130
149
|
POST /mcp remote MCP (Streamable HTTP; Bearer api key or OAuth)
|
|
131
150
|
GET /.well-known/oauth-protected-resource/mcp OAuth discovery
|
|
132
151
|
POST /v1/billing/checkout {amount_yen} -> {checkout_url} POST /v1/billing/sync GET /v1/ledger GET /v1/me
|
|
133
|
-
GET /llms.txt /terms /privacy /legal /security
|
|
152
|
+
GET /llms.txt /llms-full.txt /ja/llms.txt /faq /terms /privacy /legal /security /support
|
|
134
153
|
```
|
|
135
154
|
|
|
136
155
|
During the free beta a `429 quota_exceeded` response carries `resets_at`; a `400 beta_limit` means the job is too big
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
# JANCTION Render
|
|
2
2
|
|
|
3
|
+
[](https://glama.ai/mcp/servers/JasmyLab-JANCTION/janction-render)
|
|
4
|
+
|
|
3
5
|
A cloud GPU render farm for Blender, built for AI agents. Render Blender scenes on JANCTION GPUs from **Claude, ChatGPT,
|
|
4
6
|
Claude Code, Codex, Cursor or any MCP client**: an MCP server (remote and stdio) plus a CLI. Use it to render Blender
|
|
5
7
|
without a GPU, or when rendering locally is slow.
|
|
6
8
|
|
|
7
|
-
- Input: a `.blend` file,
|
|
9
|
+
- Input: a `.blend` file (textures travel with it), a **bpy Python script that builds the scene** (no local Blender
|
|
10
|
+
needed), or a **3D file** (glTF/GLB, FBX, USD, OBJ, STL, PLY, Alembic) that is imported into an empty scene.
|
|
11
|
+
- `environment="studio" | "sunset" | "overcast" | "night"` lights a scene with a bundled HDRI (CC0) for a good first render.
|
|
8
12
|
- `scene_info` reads the scene without rendering (cameras, frame range, missing files).
|
|
9
13
|
- `render_preview` returns 1-4 low-cost frames tiled in one image within seconds, so the agent can look, fix, and repeat.
|
|
10
14
|
- `render_estimate` says how long a render will take ("about 3 minutes") and whether it fits today's free quota.
|
|
@@ -15,7 +19,12 @@ without a GPU, or when rendering locally is slow.
|
|
|
15
19
|
|
|
16
20
|
## Status
|
|
17
21
|
|
|
18
|
-
Free beta. Service page: `https://render.janction.jp` (`/v1/health`, `/llms.txt`). Blender 5.0, Cycles on GPU
|
|
22
|
+
Free beta. Service page: `https://render.janction.jp` (`/v1/health`, `/llms.txt`, `/faq`). Blender 5.0, Cycles on GPU
|
|
23
|
+
(`blender="5.2"` selects Blender 5.2 when the worker has it; `render_info` lists what is available).
|
|
24
|
+
Guides: [render farm for agents](https://render.janction.jp/blender-render-farm) · [without a GPU](https://render.janction.jp/render-blender-without-gpu) ·
|
|
25
|
+
[Claude](https://render.janction.jp/claude-blender) · [ChatGPT](https://render.janction.jp/chatgpt-blender) · [Claude Code](https://render.janction.jp/claude-code-blender) ·
|
|
26
|
+
[Codex](https://render.janction.jp/codex-blender) · [bpy scripts](https://render.janction.jp/bpy-script-cloud-gpu) · [API](https://render.janction.jp/blender-render-api) ·
|
|
27
|
+
[comparison](https://render.janction.jp/blender-render-farm-api-comparison) · 日本語は `/ja/`
|
|
19
28
|
|
|
20
29
|
## Connect (remote MCP, nothing to install)
|
|
21
30
|
|
|
@@ -30,8 +39,9 @@ also works as `Authorization: Bearer jr_...`.
|
|
|
30
39
|
| Claude Code | `claude mcp add --transport http janction-render https://render.janction.jp/mcp`, then `/mcp` to authenticate |
|
|
31
40
|
| Cursor, Windsurf, other MCP clients | Streamable HTTP at the URL above (OAuth, or a Bearer API key header) |
|
|
32
41
|
|
|
33
|
-
Remote tools take `scene_script` (bpy code as text), `scene_url` (an https link to a `.blend` or
|
|
34
|
-
results come back as an inline image plus download links
|
|
42
|
+
Remote tools take `scene_script` (bpy code as text), `scene_url` (an https link to a `.blend`, `.py` or a 3D file) or
|
|
43
|
+
`scene_id`, plus `asset_urls` for textures or glTF `.bin` files; results come back as an inline image plus download links
|
|
44
|
+
that need no key and work for about 24 hours (`render_download(only="mp4")` for just the video).
|
|
35
45
|
|
|
36
46
|
Claude Code plugin (the remote connector plus a skill with the workflow):
|
|
37
47
|
|
|
@@ -74,13 +84,18 @@ Then, in Claude Code:
|
|
|
74
84
|
|
|
75
85
|
| tool | what |
|
|
76
86
|
|---|---|
|
|
77
|
-
| `scene_info(scene_script | scene_url | scene_path | scene_id)` | cameras, frame range, fps, resolution, objects, lights, missing files. No render. |
|
|
78
|
-
| `render_preview(..., frames="1-24")` | up to 4 frames (720p budget) tiled with frame labels; returns the image inline |
|
|
87
|
+
| `scene_info(scene_script | scene_url | scene_path | scene_id, assets?)` | cameras, frame range, fps, resolution, objects, lights, missing files. No render. |
|
|
88
|
+
| `render_preview(..., frames="1-24", environment?, blender?)` | up to 4 frames (720p budget) tiled with frame labels; returns the image inline |
|
|
79
89
|
| `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 |
|
|
80
|
-
| `render_final(scene_id, frame_start, frame_end, width, height, samples, fps, output)` | PNG or MP4; returns job_id + estimate |
|
|
81
|
-
| `render_status(job_id)` | progress and ETA (`eta.human`); `render_download(job_id)` files or links; `render_cancel(job_id)` |
|
|
90
|
+
| `render_final(scene_id, frame_start, frame_end, width, height, samples, fps, output, environment?, blender?)` | PNG or MP4; returns job_id + estimate |
|
|
91
|
+
| `render_status(job_id)` | progress and ETA (`eta.human`); `render_download(job_id, only="mp4" / "frames" / "all")` files or links; `render_cancel(job_id)` |
|
|
82
92
|
| `billing()` | free-beta quota (used today, daily limit, reset time); later balance and top-up link |
|
|
83
|
-
| `render_info()` | workers online, queue
|
|
93
|
+
| `render_info()` | workers online, queue, expected wait, supported inputs, environment presets, Blender versions |
|
|
94
|
+
|
|
95
|
+
Options on `render_preview` / `render_final`: `environment` (`studio`, `sunset`, `overcast`, `night`; `environment_strength`,
|
|
96
|
+
`environment_visible=False` for a flat grey backdrop with HDRI lighting), `blender` (`"5.2"`), and `assets` (stdio: local files
|
|
97
|
+
sent with the scene, found in the script through `os.environ["JR_ASSETS_DIR"]`; a `.blend`'s external files are collected
|
|
98
|
+
automatically with `pip install janction-render[blend]`) or `asset_urls` (remote).
|
|
84
99
|
|
|
85
100
|
CLI: `janction-render inspect|preview|render|status|download|cancel|jobs|balance|topup|info`.
|
|
86
101
|
|
|
@@ -107,15 +122,17 @@ Scripts run in an isolated container with no network. See `samples/cube_scene.py
|
|
|
107
122
|
|
|
108
123
|
```
|
|
109
124
|
POST /v1/keys -> {api_key} (header X-API-Key afterwards)
|
|
110
|
-
POST /v1/files multipart "file" (.blend|.py) -> {scene_id}
|
|
125
|
+
POST /v1/files multipart "file" (.blend|.py|.glb|.fbx|.usd|.obj|...) -> {scene_id}
|
|
126
|
+
POST /v1/files/{id}/assets multipart "files" textures, glTF .bin ... GET /v1/files/lookup?sha256= reuse an upload
|
|
111
127
|
POST /v1/estimate {kind, frames|frame_start/frame_end, width, height, samples, scene_id?} -> seconds, wall_seconds, human, quota
|
|
112
|
-
POST /v1/jobs {scene_id, kind: info|preview|final, frames|frame_start/frame_end, width, height, samples, camera, fps, output
|
|
128
|
+
POST /v1/jobs {scene_id, kind: info|preview|final, frames|frame_start/frame_end, width, height, samples, camera, fps, output,
|
|
129
|
+
environment, environment_strength, environment_visible, blender}
|
|
113
130
|
GET /v1/jobs/{id} status, progress, eta, artifacts[], cost, warnings, info DELETE /v1/jobs/{id} cancel
|
|
114
131
|
GET /v1/jobs/{id}/artifacts/{name} PNG / MP4
|
|
115
132
|
POST /mcp remote MCP (Streamable HTTP; Bearer api key or OAuth)
|
|
116
133
|
GET /.well-known/oauth-protected-resource/mcp OAuth discovery
|
|
117
134
|
POST /v1/billing/checkout {amount_yen} -> {checkout_url} POST /v1/billing/sync GET /v1/ledger GET /v1/me
|
|
118
|
-
GET /llms.txt /terms /privacy /legal /security
|
|
135
|
+
GET /llms.txt /llms-full.txt /ja/llms.txt /faq /terms /privacy /legal /security /support
|
|
119
136
|
```
|
|
120
137
|
|
|
121
138
|
During the free beta a `429 quota_exceeded` response carries `resets_at`; a `400 beta_limit` means the job is too big
|
|
@@ -1,8 +1,33 @@
|
|
|
1
1
|
"""仕事の状態を、エージェントが読みやすい短い形にする。stdio の MCP(mcp_server.py)とリモート MCP(server/mcp_remote.py)で共通。"""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
+
import time
|
|
4
5
|
from typing import Any
|
|
5
6
|
|
|
7
|
+
JST = 9 * 3600
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def iso(ts: float | int | None) -> str | None:
|
|
11
|
+
"""epoch 秒 → "2026-10-02T04:12:00Z (2026-10-02 13:12 JST)"。エージェントがそのまま伝えられる形。"""
|
|
12
|
+
if ts is None:
|
|
13
|
+
return None
|
|
14
|
+
try:
|
|
15
|
+
t = float(ts)
|
|
16
|
+
except (TypeError, ValueError):
|
|
17
|
+
return None
|
|
18
|
+
utc = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(t))
|
|
19
|
+
jst = time.strftime("%Y-%m-%d %H:%M JST", time.gmtime(t + JST))
|
|
20
|
+
return f"{utc} ({jst})"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def trim(items: list[Any] | None, keep: int = 8) -> dict[str, Any]:
|
|
24
|
+
"""長い一覧を「先頭 keep 件 + 残りの件数」にする(エージェントの文脈を食わないように)。"""
|
|
25
|
+
items = list(items or [])
|
|
26
|
+
out: dict[str, Any] = {"count": len(items), "first": items[:keep]}
|
|
27
|
+
if len(items) > keep:
|
|
28
|
+
out["more"] = len(items) - keep
|
|
29
|
+
return out
|
|
30
|
+
|
|
6
31
|
|
|
7
32
|
def eta_brief(e: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
8
33
|
"""受付の eta(作業時間の見積もり)から、伝える価値のある項目だけ。"""
|
|
@@ -33,7 +58,7 @@ def brief(j: dict[str, Any]) -> dict[str, Any]:
|
|
|
33
58
|
"queue_ahead": p["queue_ahead"],
|
|
34
59
|
"gpu_seconds": j["gpu_seconds"],
|
|
35
60
|
"device": j["device"],
|
|
36
|
-
"expires_at": j
|
|
61
|
+
"expires_at": iso(j.get("expires_at")) or j.get("expires_at"),
|
|
37
62
|
}
|
|
38
63
|
if j.get("eta"):
|
|
39
64
|
out["eta"] = eta_brief(j["eta"])
|
|
@@ -17,6 +17,77 @@ import requests
|
|
|
17
17
|
DEFAULT_SERVER = "http://127.0.0.1:8340"
|
|
18
18
|
CACHE = Path.home() / ".janction-render.json"
|
|
19
19
|
|
|
20
|
+
# 送れるシーン: .blend、bpy スクリプト、こちらの Blender が読み込める 3D ファイル
|
|
21
|
+
SCENE_SUFFIXES = (".blend", ".py", ".fbx", ".glb", ".gltf", ".obj", ".stl", ".ply", ".usd", ".usda", ".usdc", ".usdz", ".abc")
|
|
22
|
+
ONLY_CHOICES = ("all", "mp4", "frames", "sheet", "output")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def select_artifacts(artifacts: list[dict[str, Any]], only: Optional[str] = None) -> list[dict[str, Any]]:
|
|
26
|
+
"""成果物の絞り込み: all / mp4(output.mp4 だけ)/ frames(PNG のコマだけ)/ sheet(4 コマ並べだけ)/ output(mp4 か sheet)。"""
|
|
27
|
+
o = (only or "all").strip().lower()
|
|
28
|
+
if o in ("", "all"):
|
|
29
|
+
return list(artifacts)
|
|
30
|
+
if o == "mp4":
|
|
31
|
+
return [a for a in artifacts if a["name"].endswith(".mp4")]
|
|
32
|
+
if o == "frames":
|
|
33
|
+
return [a for a in artifacts if a["name"].startswith("frame_")]
|
|
34
|
+
if o == "sheet":
|
|
35
|
+
return [a for a in artifacts if a["name"] == "sheet.png"]
|
|
36
|
+
if o == "output":
|
|
37
|
+
picked = [a for a in artifacts if a["name"] in ("output.mp4", "sheet.png")]
|
|
38
|
+
return picked or [a for a in artifacts if a["name"].startswith("frame_")][:1]
|
|
39
|
+
raise ValueError("only must be one of: " + ", ".join(ONLY_CHOICES))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def sha256_of(path: Path) -> str:
|
|
43
|
+
import hashlib
|
|
44
|
+
|
|
45
|
+
h = hashlib.sha256()
|
|
46
|
+
with Path(path).open("rb") as fh:
|
|
47
|
+
for chunk in iter(lambda: fh.read(1024 * 1024), b""):
|
|
48
|
+
h.update(chunk)
|
|
49
|
+
return h.hexdigest()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def trace_blend_assets(blend: Path) -> tuple[list[tuple[str, Path]], list[str]]:
|
|
53
|
+
""".blend が参照する外部ファイル(画像・ライブラリなど)を手元で調べる(blender-asset-tracer があるとき)。
|
|
54
|
+
|
|
55
|
+
返すのは ([(送るときの名前, 場所)], [見つからなかったパス])。名前は .blend からの相対パス(//textures/a.png → textures/a.png)。
|
|
56
|
+
.blend の外(上の階層や絶対パス)にあるものは送れないので「見つからなかった」に入れる。
|
|
57
|
+
"""
|
|
58
|
+
try:
|
|
59
|
+
from blender_asset_tracer import trace # type: ignore
|
|
60
|
+
except Exception: # noqa: BLE001 — 入っていなければ調べない(受付の scene_info が GPU 側で知らせる)
|
|
61
|
+
return [], []
|
|
62
|
+
found: list[tuple[str, Path]] = []
|
|
63
|
+
missing: list[str] = []
|
|
64
|
+
seen: set[str] = set()
|
|
65
|
+
base = Path(blend).resolve().parent
|
|
66
|
+
try:
|
|
67
|
+
for usage in trace.deps(Path(blend).resolve()):
|
|
68
|
+
try:
|
|
69
|
+
p = Path(usage.abspath)
|
|
70
|
+
except Exception: # noqa: BLE001
|
|
71
|
+
continue
|
|
72
|
+
key = str(p)
|
|
73
|
+
if key in seen:
|
|
74
|
+
continue
|
|
75
|
+
seen.add(key)
|
|
76
|
+
if usage.is_sequence:
|
|
77
|
+
continue
|
|
78
|
+
if not p.exists():
|
|
79
|
+
missing.append(str(usage.asset_path))
|
|
80
|
+
continue
|
|
81
|
+
try:
|
|
82
|
+
rel = p.resolve().relative_to(base)
|
|
83
|
+
except ValueError:
|
|
84
|
+
missing.append(f"{usage.asset_path} (outside the .blend folder; cannot be sent)")
|
|
85
|
+
continue
|
|
86
|
+
found.append((rel.as_posix(), p))
|
|
87
|
+
except Exception: # noqa: BLE001
|
|
88
|
+
return found, missing
|
|
89
|
+
return found, missing
|
|
90
|
+
|
|
20
91
|
|
|
21
92
|
def parse_frames(spec: str, default: int = 1, count: int = 4) -> list[int]:
|
|
22
93
|
"""試し描きのコマの指定を配列にする。
|
|
@@ -155,15 +226,72 @@ class Client:
|
|
|
155
226
|
def me(self) -> dict[str, Any]:
|
|
156
227
|
return self._req("GET", "/v1/me")
|
|
157
228
|
|
|
158
|
-
def
|
|
229
|
+
def lookup(self, sha256: str) -> Optional[dict[str, Any]]:
|
|
230
|
+
"""同じ中身を送ってあれば、その scene_id(無ければ None)。"""
|
|
231
|
+
r = self.s.get(f"{self.server}/v1/files/lookup", params={"sha256": sha256}, headers=self._headers(), timeout=30)
|
|
232
|
+
if r.status_code == 404:
|
|
233
|
+
return None
|
|
234
|
+
self._raise(r)
|
|
235
|
+
return r.json()
|
|
236
|
+
|
|
237
|
+
def upload(self, path: str | Path, assets: Optional[list[str | Path]] = None,
|
|
238
|
+
trace_assets: bool = True) -> dict[str, Any]:
|
|
239
|
+
"""シーンを送る。同じ中身を送ってあれば送り直さない(sha256 で照合)。
|
|
240
|
+
|
|
241
|
+
assets: 一緒に送る素材(画像など)。.blend は blender-asset-tracer があれば参照する外部ファイルを自動で集める。
|
|
242
|
+
返り値に assets(送った素材)と missing_assets(見つからなかった参照)が入る。
|
|
243
|
+
"""
|
|
159
244
|
p = Path(path)
|
|
160
245
|
if not p.is_file():
|
|
161
246
|
raise FileNotFoundError(f"{p} is not a file")
|
|
162
|
-
if p.suffix.lower() not in
|
|
163
|
-
raise ValueError("scene must be a .blend file
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
247
|
+
if p.suffix.lower() not in SCENE_SUFFIXES:
|
|
248
|
+
raise ValueError("scene must be a .blend file, a Blender Python script (.py), or a 3D file "
|
|
249
|
+
"(" + ", ".join(SCENE_SUFFIXES[2:]) + ")")
|
|
250
|
+
digest = sha256_of(p)
|
|
251
|
+
out: Optional[dict[str, Any]] = None
|
|
252
|
+
try:
|
|
253
|
+
out = self.lookup(digest)
|
|
254
|
+
except ClientError:
|
|
255
|
+
out = None
|
|
256
|
+
if out is None:
|
|
257
|
+
with p.open("rb") as fh:
|
|
258
|
+
r = self.s.post(f"{self.server}/v1/files", headers=self._headers(),
|
|
259
|
+
files={"file": (p.name, fh, "application/octet-stream")}, timeout=1800)
|
|
260
|
+
self._raise(r)
|
|
261
|
+
out = r.json()
|
|
262
|
+
items: list[tuple[str, Path]] = []
|
|
263
|
+
missing: list[str] = []
|
|
264
|
+
if p.suffix.lower() == ".blend" and trace_assets:
|
|
265
|
+
items, missing = trace_blend_assets(p)
|
|
266
|
+
for a in assets or []:
|
|
267
|
+
ap = Path(a)
|
|
268
|
+
if not ap.is_file():
|
|
269
|
+
raise FileNotFoundError(f"asset {ap} is not a file")
|
|
270
|
+
try:
|
|
271
|
+
name = ap.resolve().relative_to(p.resolve().parent).as_posix()
|
|
272
|
+
except ValueError:
|
|
273
|
+
name = ap.name
|
|
274
|
+
items.append((name, ap))
|
|
275
|
+
already = {a["name"] for a in out.get("assets") or []}
|
|
276
|
+
todo = [(n, ap) for n, ap in items if n not in already]
|
|
277
|
+
if todo:
|
|
278
|
+
out = self.upload_assets(out["scene_id"], todo)
|
|
279
|
+
out["missing_assets"] = missing
|
|
280
|
+
return out
|
|
281
|
+
|
|
282
|
+
def upload_assets(self, scene_id: str, items: list[tuple[str, str | Path]]) -> dict[str, Any]:
|
|
283
|
+
"""素材をシーンに足す。items は [(送るときの名前, 場所)]。名前にフォルダを含めてよい(textures/a.png)。"""
|
|
284
|
+
handles = []
|
|
285
|
+
try:
|
|
286
|
+
files = []
|
|
287
|
+
for name, path in items:
|
|
288
|
+
fh = Path(path).open("rb")
|
|
289
|
+
handles.append(fh)
|
|
290
|
+
files.append(("files", (name, fh, "application/octet-stream")))
|
|
291
|
+
r = self.s.post(f"{self.server}/v1/files/{scene_id}/assets", headers=self._headers(), files=files, timeout=1800)
|
|
292
|
+
finally:
|
|
293
|
+
for fh in handles:
|
|
294
|
+
fh.close()
|
|
167
295
|
self._raise(r)
|
|
168
296
|
return r.json()
|
|
169
297
|
|
|
@@ -230,17 +358,17 @@ class Client:
|
|
|
230
358
|
return j
|
|
231
359
|
time.sleep(interval)
|
|
232
360
|
|
|
233
|
-
def download(self, job_id: str, out_dir: str | Path, only: Optional[list[str]] = None) -> list[Path]:
|
|
234
|
-
"""成果物を out_dir に落とす。only
|
|
361
|
+
def download(self, job_id: str, out_dir: str | Path, only: Optional[list[str] | str] = None) -> list[Path]:
|
|
362
|
+
"""成果物を out_dir に落とす。only は名前の一覧か、'mp4' / 'frames' / 'sheet' / 'output' / 'all'。"""
|
|
235
363
|
j = self.job(job_id)
|
|
236
364
|
out = Path(out_dir)
|
|
237
365
|
out.mkdir(parents=True, exist_ok=True)
|
|
238
366
|
paths: list[Path] = []
|
|
239
367
|
from urllib.parse import urlsplit
|
|
240
368
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
369
|
+
wanted = select_artifacts(j["artifacts"], only) if isinstance(only, str) or only is None else \
|
|
370
|
+
[a for a in j["artifacts"] if a["name"] in only]
|
|
371
|
+
for a in wanted:
|
|
244
372
|
# 受付が返す URL は公開 URL の土台で組まれている。自分がつないだ先(SSH トンネルなど)でも
|
|
245
373
|
# 落とせるように、パスだけを取り出して自分の server に付け直す
|
|
246
374
|
parts = urlsplit(a["url"])
|
|
@@ -15,7 +15,7 @@ import json
|
|
|
15
15
|
import os
|
|
16
16
|
import sys
|
|
17
17
|
from pathlib import Path
|
|
18
|
-
from typing import Any
|
|
18
|
+
from typing import Any, Optional
|
|
19
19
|
|
|
20
20
|
if __package__ in (None, ""):
|
|
21
21
|
# ファイルとして直接起動されたとき(claude mcp add ... server.py)
|
|
@@ -26,7 +26,7 @@ try:
|
|
|
26
26
|
except ImportError: # mcp 1.x
|
|
27
27
|
from mcp.server.fastmcp import FastMCP as MCPServer, Image # type: ignore
|
|
28
28
|
|
|
29
|
-
from janction_render.brief import brief as _brief, estimate_brief
|
|
29
|
+
from janction_render.brief import brief as _brief, estimate_brief, trim
|
|
30
30
|
from janction_render.client import Client, ClientError, parse_frames
|
|
31
31
|
|
|
32
32
|
PREVIEW_WAIT_S = 240.0
|
|
@@ -41,13 +41,23 @@ 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.
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
"
|
|
44
|
+
"Blender 5.0 with Cycles on GPU (blender='5.2' selects Blender 5.2 when the worker has it). Inputs: a .blend, a bpy "
|
|
45
|
+
"script, or a 3D file (FBX, glTF/GLB, USD, OBJ, STL, PLY, Alembic) that is imported into an empty scene; images "
|
|
46
|
+
"and other assets can be sent with the scene (assets=[...]). environment='studio'|'sunset'|'overcast'|'night' "
|
|
47
|
+
"lights the scene with a bundled HDRI for a good-looking first render. Inputs and results are deleted 24 hours "
|
|
48
|
+
"after last use. Use it when: the user asks to render a Blender file or bpy script, an agent-made Blender scene "
|
|
49
|
+
"needs its final image or video, the machine has no NVIDIA GPU or local rendering is slow, or many frames need "
|
|
50
|
+
"GPU rendering. Do not use it for modeling without rendering or for non-Blender workloads."
|
|
48
51
|
),
|
|
49
52
|
)
|
|
50
53
|
|
|
54
|
+
ENV_DOC = ("environment: '' keeps the scene's own world; 'studio' | 'sunset' | 'overcast' | 'night' replaces it with a "
|
|
55
|
+
"bundled HDRI (good first render for scenes without lighting work); environment_strength scales it (1.0); "
|
|
56
|
+
"environment_visible=False hides the HDRI from the camera (flat grey backdrop, HDRI lighting only). "
|
|
57
|
+
"blender: '' (5.0) or '5.2' to render with Blender 5.2 when available. assets: local files (images, glTF "
|
|
58
|
+
".bin, ...) sent along with the scene; a .blend finds them through relative paths, a script through "
|
|
59
|
+
"os.environ['JR_ASSETS_DIR'].")
|
|
60
|
+
|
|
51
61
|
|
|
52
62
|
def _log(msg: str) -> None:
|
|
53
63
|
print(f"[janction-render] {msg}", file=sys.stderr, flush=True)
|
|
@@ -87,36 +97,78 @@ def _payment_block(exc: ClientError) -> Optional[dict[str, Any]]:
|
|
|
87
97
|
return p
|
|
88
98
|
|
|
89
99
|
|
|
90
|
-
|
|
100
|
+
_LAST_UPLOAD: dict[str, Any] = {}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _resolve_scene(c: Client, scene_path: str, scene_id: str, scene_script: str = "",
|
|
104
|
+
assets: Optional[list[str]] = None) -> str:
|
|
105
|
+
_LAST_UPLOAD.clear()
|
|
91
106
|
if scene_id:
|
|
107
|
+
if assets:
|
|
108
|
+
up = c.upload_assets(scene_id, [(Path(a).name, a) for a in assets])
|
|
109
|
+
_LAST_UPLOAD.update({"assets": [x["name"] for x in up.get("assets") or []]})
|
|
92
110
|
return scene_id
|
|
93
111
|
if scene_script.strip():
|
|
94
112
|
up = c.upload_text("scene.py", scene_script)
|
|
95
113
|
_log(f"uploaded inline script -> {up['scene_id']} ({up['size']} bytes)")
|
|
114
|
+
if assets:
|
|
115
|
+
up = c.upload_assets(up["scene_id"], [(Path(a).name, a) for a in assets])
|
|
116
|
+
_LAST_UPLOAD.update({"assets": [x["name"] for x in up.get("assets") or []]})
|
|
96
117
|
return up["scene_id"]
|
|
97
118
|
if not scene_path:
|
|
98
|
-
raise ValueError("pass scene_path (a .blend
|
|
99
|
-
"or scene_id from a previous call")
|
|
100
|
-
up = c.upload(scene_path)
|
|
101
|
-
_log(f"uploaded {up['name']} -> {up['scene_id']} ({up['size']} bytes)")
|
|
119
|
+
raise ValueError("pass scene_path (a .blend, a bpy .py script, or a 3D file such as .glb/.fbx/.usd), "
|
|
120
|
+
"scene_script (bpy code as text), or scene_id from a previous call")
|
|
121
|
+
up = c.upload(scene_path, assets=list(assets or []))
|
|
122
|
+
_log(f"uploaded {up['name']} -> {up['scene_id']} ({up['size']} bytes, {len(up.get('assets') or [])} assets)")
|
|
123
|
+
if up.get("assets"):
|
|
124
|
+
_LAST_UPLOAD["assets"] = [x["name"] for x in up["assets"]]
|
|
125
|
+
if up.get("missing_assets"):
|
|
126
|
+
_LAST_UPLOAD["missing_assets"] = up["missing_assets"]
|
|
127
|
+
if up.get("reused"):
|
|
128
|
+
_LAST_UPLOAD["reused"] = True
|
|
102
129
|
return up["scene_id"]
|
|
103
130
|
|
|
104
131
|
|
|
132
|
+
def _job_params(environment: str, environment_strength: float, environment_visible: bool, blender: str) -> dict[str, Any]:
|
|
133
|
+
out: dict[str, Any] = {}
|
|
134
|
+
if environment:
|
|
135
|
+
out.update({"environment": environment, "environment_strength": environment_strength,
|
|
136
|
+
"environment_visible": environment_visible})
|
|
137
|
+
if blender:
|
|
138
|
+
out["blender"] = blender
|
|
139
|
+
return out
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _attach_upload_notes(obj: dict[str, Any]) -> dict[str, Any]:
|
|
143
|
+
if _LAST_UPLOAD.get("missing_assets"):
|
|
144
|
+
obj["missing_assets"] = _LAST_UPLOAD["missing_assets"]
|
|
145
|
+
obj["missing_assets_note"] = ("these files referenced by the .blend were not found next to it and were not sent; "
|
|
146
|
+
"they will render pink. Pack them (File > External Data > Pack Resources) or put them "
|
|
147
|
+
"next to the .blend")
|
|
148
|
+
if _LAST_UPLOAD.get("assets"):
|
|
149
|
+
obj["assets_sent"] = trim(_LAST_UPLOAD["assets"], 10)
|
|
150
|
+
if _LAST_UPLOAD.get("reused"):
|
|
151
|
+
obj["upload_note"] = "same file was already on the server; not re-uploaded"
|
|
152
|
+
return obj
|
|
153
|
+
|
|
154
|
+
|
|
105
155
|
@mcp.tool()
|
|
106
|
-
def scene_info(scene_path: str = "", scene_id: str = "", scene_script: str = ""
|
|
156
|
+
def scene_info(scene_path: str = "", scene_id: str = "", scene_script: str = "", assets: Optional[list[str]] = None,
|
|
157
|
+
blender: str = "") -> str:
|
|
107
158
|
"""Read a Blender scene WITHOUT rendering: cameras (and which is active), frame range and fps,
|
|
108
159
|
resolution, engine, objects by type, lights, materials, whether it is animated, and any linked
|
|
109
160
|
files that are missing (textures etc. that would render pink). Takes a few seconds, no GPU.
|
|
110
161
|
|
|
111
|
-
Give the scene as scene_path (.blend
|
|
112
|
-
|
|
113
|
-
|
|
162
|
+
Give the scene as scene_path (a .blend, a bpy .py script, or a 3D file: .glb/.gltf/.fbx/.usd/.obj/.stl/.ply/.abc,
|
|
163
|
+
imported into an empty scene), scene_script (bpy Python code as text, no file needed), or scene_id from a
|
|
164
|
+
previous call. assets: local files to send with the scene (textures, glTF .bin). Call this first for a .blend
|
|
165
|
+
you did not write yourself, or after writing a bpy script to check what it produced. Use the camera names and
|
|
114
166
|
frame range in render_preview / render_final. Returns scene_id to reuse in the next calls.
|
|
115
167
|
"""
|
|
116
168
|
c = _client()
|
|
117
169
|
try:
|
|
118
|
-
sid = _resolve_scene(c, scene_path, scene_id, scene_script)
|
|
119
|
-
j = c.submit(sid, kind="info")
|
|
170
|
+
sid = _resolve_scene(c, scene_path, scene_id, scene_script, assets)
|
|
171
|
+
j = c.submit(sid, kind="info", **_job_params("", 1.0, True, blender))
|
|
120
172
|
if j["status"] != "done":
|
|
121
173
|
j = c.wait(j["job_id"], timeout=INFO_WAIT_S)
|
|
122
174
|
except (ClientError, ValueError, FileNotFoundError) as exc:
|
|
@@ -133,8 +185,11 @@ def scene_info(scene_path: str = "", scene_id: str = "", scene_script: str = "")
|
|
|
133
185
|
if j.get("warnings"):
|
|
134
186
|
info["blender_warnings"] = j["warnings"]
|
|
135
187
|
if info.get("missing_files"):
|
|
136
|
-
info["note"] = "missing_files will render pink/black;
|
|
137
|
-
|
|
188
|
+
info["note"] = ("missing_files will render pink/black; send them with assets=[...] (kept next to the .blend), "
|
|
189
|
+
"pack them into the .blend (File > External Data > Pack) or fix the paths")
|
|
190
|
+
if isinstance(info.get("object_names"), list):
|
|
191
|
+
info["object_names"] = trim(info["object_names"], 30)
|
|
192
|
+
return _j(_attach_upload_notes(info))
|
|
138
193
|
|
|
139
194
|
|
|
140
195
|
@mcp.tool(structured_output=False)
|
|
@@ -148,6 +203,11 @@ def render_preview(
|
|
|
148
203
|
height: int = 720,
|
|
149
204
|
samples: int = 16,
|
|
150
205
|
out_dir: str = "",
|
|
206
|
+
environment: str = "",
|
|
207
|
+
environment_strength: float = 1.0,
|
|
208
|
+
environment_visible: bool = True,
|
|
209
|
+
blender: str = "",
|
|
210
|
+
assets: Optional[list[str]] = None,
|
|
151
211
|
) -> list[Any]:
|
|
152
212
|
"""Render a fast, cheap preview of a Blender scene on a JANCTION GPU and show the image.
|
|
153
213
|
|
|
@@ -161,13 +221,14 @@ def render_preview(
|
|
|
161
221
|
Look at the returned image, fix the scene, preview again; when it looks right, ask the user and
|
|
162
222
|
call render_final. Returns scene_id (reuse it without re-uploading), job_id, saved PNG paths,
|
|
163
223
|
Blender warnings (e.g. missing textures), GPU seconds, and the expiry time (24h after last use).
|
|
164
|
-
"""
|
|
224
|
+
""" + ENV_DOC
|
|
165
225
|
c = _client()
|
|
166
226
|
try:
|
|
167
227
|
frame_list = parse_frames(frames)
|
|
168
|
-
sid = _resolve_scene(c, scene_path, scene_id, scene_script)
|
|
228
|
+
sid = _resolve_scene(c, scene_path, scene_id, scene_script, assets)
|
|
169
229
|
j = c.submit(sid, kind="preview", frames=frame_list, camera=camera or None,
|
|
170
|
-
width=width, height=height, samples=samples
|
|
230
|
+
width=width, height=height, samples=samples,
|
|
231
|
+
**_job_params(environment, environment_strength, environment_visible, blender))
|
|
171
232
|
_log(f"preview job {j['job_id']} submitted frames={frame_list}")
|
|
172
233
|
j = c.wait(j["job_id"], timeout=PREVIEW_WAIT_S)
|
|
173
234
|
except ClientError as exc:
|
|
@@ -187,6 +248,7 @@ def render_preview(
|
|
|
187
248
|
brief["files"] = [str(p) for p in paths]
|
|
188
249
|
brief["next"] = ("look at the image; if the scene needs changes, edit it and call render_preview again; "
|
|
189
250
|
"if it looks right, ask the user before calling render_final")
|
|
251
|
+
_attach_upload_notes(brief)
|
|
190
252
|
result: list[Any] = [_j(brief)]
|
|
191
253
|
if show is not None:
|
|
192
254
|
result.append(Image(data=show.read_bytes(), format="png"))
|
|
@@ -237,22 +299,28 @@ def render_final(
|
|
|
237
299
|
fps: int = 24,
|
|
238
300
|
output: str = "auto",
|
|
239
301
|
camera: str = "",
|
|
302
|
+
environment: str = "",
|
|
303
|
+
environment_strength: float = 1.0,
|
|
304
|
+
environment_visible: bool = True,
|
|
305
|
+
blender: str = "",
|
|
306
|
+
assets: Optional[list[str]] = None,
|
|
240
307
|
) -> str:
|
|
241
308
|
"""Render the final frames (or a video) of a Blender scene on JANCTION GPUs.
|
|
242
309
|
|
|
243
310
|
Call this after the user approved a preview. Pass scene_id from render_preview (or scene_path /
|
|
244
|
-
scene_script to upload a new .blend / bpy script). frame_start..frame_end are inclusive; several frames are
|
|
311
|
+
scene_script to upload a new .blend / bpy script / 3D file). frame_start..frame_end are inclusive; several frames are
|
|
245
312
|
split across GPUs and joined into output.mp4 (output='mp4', fps), a single frame gives a PNG
|
|
246
|
-
(output='png'; 'auto' picks).
|
|
247
|
-
in the background: poll with render_status, then fetch with
|
|
248
|
-
results are deleted 24 hours after last use, so download them.
|
|
249
|
-
"""
|
|
313
|
+
(output='png'; 'auto' picks). Use the same environment / blender as the approved preview. Returns job_id and a
|
|
314
|
+
time estimate right away; the render runs in the background: poll with render_status, then fetch with
|
|
315
|
+
render_download. Inputs and results are deleted 24 hours after last use, so download them.
|
|
316
|
+
""" + ENV_DOC
|
|
250
317
|
c = _client()
|
|
251
318
|
try:
|
|
252
|
-
sid = _resolve_scene(c, scene_path, scene_id, scene_script)
|
|
319
|
+
sid = _resolve_scene(c, scene_path, scene_id, scene_script, assets)
|
|
253
320
|
j = c.submit(sid, kind="final", frame_start=frame_start, frame_end=frame_end,
|
|
254
321
|
width=width, height=height, samples=samples, fps=fps, output=output,
|
|
255
|
-
camera=camera or None
|
|
322
|
+
camera=camera or None,
|
|
323
|
+
**_job_params(environment, environment_strength, environment_visible, blender))
|
|
256
324
|
except ClientError as exc:
|
|
257
325
|
block = _payment_block(exc) or _quota_block(exc)
|
|
258
326
|
return _j(block if block else {"ok": False, "error": str(exc)})
|
|
@@ -261,8 +329,8 @@ def render_final(
|
|
|
261
329
|
brief = _brief(j)
|
|
262
330
|
est = j.get("estimate") or {}
|
|
263
331
|
brief["next"] = (f"tell the user it will take {est.get('human', 'a few minutes')}; then render_status(job_id) "
|
|
264
|
-
"until status is done, then render_download(job_id)")
|
|
265
|
-
return _j(brief)
|
|
332
|
+
"until status is done, then render_download(job_id, only='mp4') for the video")
|
|
333
|
+
return _j(_attach_upload_notes(brief))
|
|
266
334
|
|
|
267
335
|
|
|
268
336
|
@mcp.tool()
|
|
@@ -282,10 +350,12 @@ def render_status(job_id: str) -> str:
|
|
|
282
350
|
|
|
283
351
|
|
|
284
352
|
@mcp.tool()
|
|
285
|
-
def render_download(job_id: str, out_dir: str = "", wait_seconds: int = 0) -> str:
|
|
286
|
-
"""Download a JANCTION render job's files
|
|
287
|
-
(default
|
|
288
|
-
|
|
353
|
+
def render_download(job_id: str, out_dir: str = "", wait_seconds: int = 0, only: str = "all") -> str:
|
|
354
|
+
"""Download a JANCTION render job's files to out_dir (default ~/janction-render/<job_id>).
|
|
355
|
+
only: 'all' (default: every PNG frame plus output.mp4 / sheet.png), 'mp4' (just the video), 'frames'
|
|
356
|
+
(just the PNG frames), 'sheet' (just the preview sheet), 'output' (the MP4 or the sheet only). Pass only='mp4'
|
|
357
|
+
when the user wants the video: it avoids downloading hundreds of frames. wait_seconds > 0 first waits up to
|
|
358
|
+
that long for the job to finish. Returns the local file paths (long lists are summarised)."""
|
|
289
359
|
c = _client()
|
|
290
360
|
try:
|
|
291
361
|
j = c.wait(job_id, timeout=float(wait_seconds)) if wait_seconds > 0 else c.job(job_id)
|
|
@@ -294,11 +364,17 @@ def render_download(job_id: str, out_dir: str = "", wait_seconds: int = 0) -> st
|
|
|
294
364
|
b["hint"] = "nothing to download yet"
|
|
295
365
|
return _j(b)
|
|
296
366
|
out = Path(out_dir) if out_dir else _out_root() / job_id
|
|
297
|
-
paths = c.download(job_id, out)
|
|
298
|
-
except ClientError as exc:
|
|
367
|
+
paths = c.download(job_id, out, only=only)
|
|
368
|
+
except (ClientError, ValueError) as exc:
|
|
299
369
|
return _j({"ok": False, "error": str(exc)})
|
|
300
370
|
b = _brief(j)
|
|
301
|
-
|
|
371
|
+
names = [str(p) for p in paths]
|
|
372
|
+
b["output_file"] = next((n for n in names if n.endswith("output.mp4") or n.endswith("sheet.png")), None)
|
|
373
|
+
b["files"] = trim(names, 6)
|
|
374
|
+
b["folder"] = str(out)
|
|
375
|
+
skipped = len(j["artifacts"]) - len(paths)
|
|
376
|
+
if skipped > 0:
|
|
377
|
+
b["not_downloaded"] = f"{skipped} other files (frames); call again with only='frames' or 'all' if needed"
|
|
302
378
|
if j["status"] != "done":
|
|
303
379
|
b["hint"] = "job is not finished; these are the frames ready so far"
|
|
304
380
|
return _j(b)
|
|
@@ -358,6 +434,7 @@ def render_info() -> str:
|
|
|
358
434
|
except (ClientError, Exception) as exc: # noqa: BLE001
|
|
359
435
|
return _j({"ok": False, "server": c.server, "error": str(exc)})
|
|
360
436
|
return _j({"ok": True, "server": c.server, "workers": health["workers"], "queue": health["queue"],
|
|
437
|
+
"features": health.get("features"),
|
|
361
438
|
"key": me, "downloads_go_to": str(_out_root()),
|
|
362
439
|
"remote_mcp": f"{c.server}/mcp (Streamable HTTP; for Claude.ai, ChatGPT, Cursor: add this URL as a connector)"})
|
|
363
440
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: janction-render
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.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
|
|
@@ -9,17 +9,23 @@ Requires-Dist: requests>=2.31
|
|
|
9
9
|
Requires-Dist: mcp>=1.10
|
|
10
10
|
Provides-Extra: mcp
|
|
11
11
|
Requires-Dist: mcp>=1.10; extra == "mcp"
|
|
12
|
+
Provides-Extra: blend
|
|
13
|
+
Requires-Dist: blender-asset-tracer>=1.20; extra == "blend"
|
|
12
14
|
Provides-Extra: dev
|
|
13
15
|
Requires-Dist: pytest>=8; extra == "dev"
|
|
14
16
|
Dynamic: license-file
|
|
15
17
|
|
|
16
18
|
# JANCTION Render
|
|
17
19
|
|
|
20
|
+
[](https://glama.ai/mcp/servers/JasmyLab-JANCTION/janction-render)
|
|
21
|
+
|
|
18
22
|
A cloud GPU render farm for Blender, built for AI agents. Render Blender scenes on JANCTION GPUs from **Claude, ChatGPT,
|
|
19
23
|
Claude Code, Codex, Cursor or any MCP client**: an MCP server (remote and stdio) plus a CLI. Use it to render Blender
|
|
20
24
|
without a GPU, or when rendering locally is slow.
|
|
21
25
|
|
|
22
|
-
- Input: a `.blend` file,
|
|
26
|
+
- Input: a `.blend` file (textures travel with it), a **bpy Python script that builds the scene** (no local Blender
|
|
27
|
+
needed), or a **3D file** (glTF/GLB, FBX, USD, OBJ, STL, PLY, Alembic) that is imported into an empty scene.
|
|
28
|
+
- `environment="studio" | "sunset" | "overcast" | "night"` lights a scene with a bundled HDRI (CC0) for a good first render.
|
|
23
29
|
- `scene_info` reads the scene without rendering (cameras, frame range, missing files).
|
|
24
30
|
- `render_preview` returns 1-4 low-cost frames tiled in one image within seconds, so the agent can look, fix, and repeat.
|
|
25
31
|
- `render_estimate` says how long a render will take ("about 3 minutes") and whether it fits today's free quota.
|
|
@@ -30,7 +36,12 @@ without a GPU, or when rendering locally is slow.
|
|
|
30
36
|
|
|
31
37
|
## Status
|
|
32
38
|
|
|
33
|
-
Free beta. Service page: `https://render.janction.jp` (`/v1/health`, `/llms.txt`). Blender 5.0, Cycles on GPU
|
|
39
|
+
Free beta. Service page: `https://render.janction.jp` (`/v1/health`, `/llms.txt`, `/faq`). Blender 5.0, Cycles on GPU
|
|
40
|
+
(`blender="5.2"` selects Blender 5.2 when the worker has it; `render_info` lists what is available).
|
|
41
|
+
Guides: [render farm for agents](https://render.janction.jp/blender-render-farm) · [without a GPU](https://render.janction.jp/render-blender-without-gpu) ·
|
|
42
|
+
[Claude](https://render.janction.jp/claude-blender) · [ChatGPT](https://render.janction.jp/chatgpt-blender) · [Claude Code](https://render.janction.jp/claude-code-blender) ·
|
|
43
|
+
[Codex](https://render.janction.jp/codex-blender) · [bpy scripts](https://render.janction.jp/bpy-script-cloud-gpu) · [API](https://render.janction.jp/blender-render-api) ·
|
|
44
|
+
[comparison](https://render.janction.jp/blender-render-farm-api-comparison) · 日本語は `/ja/`
|
|
34
45
|
|
|
35
46
|
## Connect (remote MCP, nothing to install)
|
|
36
47
|
|
|
@@ -45,8 +56,9 @@ also works as `Authorization: Bearer jr_...`.
|
|
|
45
56
|
| Claude Code | `claude mcp add --transport http janction-render https://render.janction.jp/mcp`, then `/mcp` to authenticate |
|
|
46
57
|
| Cursor, Windsurf, other MCP clients | Streamable HTTP at the URL above (OAuth, or a Bearer API key header) |
|
|
47
58
|
|
|
48
|
-
Remote tools take `scene_script` (bpy code as text), `scene_url` (an https link to a `.blend` or
|
|
49
|
-
results come back as an inline image plus download links
|
|
59
|
+
Remote tools take `scene_script` (bpy code as text), `scene_url` (an https link to a `.blend`, `.py` or a 3D file) or
|
|
60
|
+
`scene_id`, plus `asset_urls` for textures or glTF `.bin` files; results come back as an inline image plus download links
|
|
61
|
+
that need no key and work for about 24 hours (`render_download(only="mp4")` for just the video).
|
|
50
62
|
|
|
51
63
|
Claude Code plugin (the remote connector plus a skill with the workflow):
|
|
52
64
|
|
|
@@ -89,13 +101,18 @@ Then, in Claude Code:
|
|
|
89
101
|
|
|
90
102
|
| tool | what |
|
|
91
103
|
|---|---|
|
|
92
|
-
| `scene_info(scene_script | scene_url | scene_path | scene_id)` | cameras, frame range, fps, resolution, objects, lights, missing files. No render. |
|
|
93
|
-
| `render_preview(..., frames="1-24")` | up to 4 frames (720p budget) tiled with frame labels; returns the image inline |
|
|
104
|
+
| `scene_info(scene_script | scene_url | scene_path | scene_id, assets?)` | cameras, frame range, fps, resolution, objects, lights, missing files. No render. |
|
|
105
|
+
| `render_preview(..., frames="1-24", environment?, blender?)` | up to 4 frames (720p budget) tiled with frame labels; returns the image inline |
|
|
94
106
|
| `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 |
|
|
95
|
-
| `render_final(scene_id, frame_start, frame_end, width, height, samples, fps, output)` | PNG or MP4; returns job_id + estimate |
|
|
96
|
-
| `render_status(job_id)` | progress and ETA (`eta.human`); `render_download(job_id)` files or links; `render_cancel(job_id)` |
|
|
107
|
+
| `render_final(scene_id, frame_start, frame_end, width, height, samples, fps, output, environment?, blender?)` | PNG or MP4; returns job_id + estimate |
|
|
108
|
+
| `render_status(job_id)` | progress and ETA (`eta.human`); `render_download(job_id, only="mp4" / "frames" / "all")` files or links; `render_cancel(job_id)` |
|
|
97
109
|
| `billing()` | free-beta quota (used today, daily limit, reset time); later balance and top-up link |
|
|
98
|
-
| `render_info()` | workers online, queue
|
|
110
|
+
| `render_info()` | workers online, queue, expected wait, supported inputs, environment presets, Blender versions |
|
|
111
|
+
|
|
112
|
+
Options on `render_preview` / `render_final`: `environment` (`studio`, `sunset`, `overcast`, `night`; `environment_strength`,
|
|
113
|
+
`environment_visible=False` for a flat grey backdrop with HDRI lighting), `blender` (`"5.2"`), and `assets` (stdio: local files
|
|
114
|
+
sent with the scene, found in the script through `os.environ["JR_ASSETS_DIR"]`; a `.blend`'s external files are collected
|
|
115
|
+
automatically with `pip install janction-render[blend]`) or `asset_urls` (remote).
|
|
99
116
|
|
|
100
117
|
CLI: `janction-render inspect|preview|render|status|download|cancel|jobs|balance|topup|info`.
|
|
101
118
|
|
|
@@ -122,15 +139,17 @@ Scripts run in an isolated container with no network. See `samples/cube_scene.py
|
|
|
122
139
|
|
|
123
140
|
```
|
|
124
141
|
POST /v1/keys -> {api_key} (header X-API-Key afterwards)
|
|
125
|
-
POST /v1/files multipart "file" (.blend|.py) -> {scene_id}
|
|
142
|
+
POST /v1/files multipart "file" (.blend|.py|.glb|.fbx|.usd|.obj|...) -> {scene_id}
|
|
143
|
+
POST /v1/files/{id}/assets multipart "files" textures, glTF .bin ... GET /v1/files/lookup?sha256= reuse an upload
|
|
126
144
|
POST /v1/estimate {kind, frames|frame_start/frame_end, width, height, samples, scene_id?} -> seconds, wall_seconds, human, quota
|
|
127
|
-
POST /v1/jobs {scene_id, kind: info|preview|final, frames|frame_start/frame_end, width, height, samples, camera, fps, output
|
|
145
|
+
POST /v1/jobs {scene_id, kind: info|preview|final, frames|frame_start/frame_end, width, height, samples, camera, fps, output,
|
|
146
|
+
environment, environment_strength, environment_visible, blender}
|
|
128
147
|
GET /v1/jobs/{id} status, progress, eta, artifacts[], cost, warnings, info DELETE /v1/jobs/{id} cancel
|
|
129
148
|
GET /v1/jobs/{id}/artifacts/{name} PNG / MP4
|
|
130
149
|
POST /mcp remote MCP (Streamable HTTP; Bearer api key or OAuth)
|
|
131
150
|
GET /.well-known/oauth-protected-resource/mcp OAuth discovery
|
|
132
151
|
POST /v1/billing/checkout {amount_yen} -> {checkout_url} POST /v1/billing/sync GET /v1/ledger GET /v1/me
|
|
133
|
-
GET /llms.txt /terms /privacy /legal /security
|
|
152
|
+
GET /llms.txt /llms-full.txt /ja/llms.txt /faq /terms /privacy /legal /security /support
|
|
134
153
|
```
|
|
135
154
|
|
|
136
155
|
During the free beta a `429 quota_exceeded` response carries `resets_at`; a `400 beta_limit` means the job is too big
|
|
@@ -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.3.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"
|
|
@@ -12,6 +12,7 @@ dependencies = ["requests>=2.31", "mcp>=1.10"]
|
|
|
12
12
|
|
|
13
13
|
[project.optional-dependencies]
|
|
14
14
|
mcp = ["mcp>=1.10"]
|
|
15
|
+
blend = ["blender-asset-tracer>=1.20"]
|
|
15
16
|
dev = ["pytest>=8"]
|
|
16
17
|
|
|
17
18
|
[project.scripts]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{janction_render-0.2.1 → janction_render-0.3.0}/janction_render.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|