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