janction-render 0.2.0__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.0 → janction_render-0.3.0}/PKG-INFO +35 -16
- {janction_render-0.2.0 → janction_render-0.3.0}/README.md +32 -15
- {janction_render-0.2.0 → janction_render-0.3.0}/janction_render/__init__.py +1 -1
- {janction_render-0.2.0 → janction_render-0.3.0}/janction_render/brief.py +26 -1
- {janction_render-0.2.0 → janction_render-0.3.0}/janction_render/client.py +139 -11
- {janction_render-0.2.0 → janction_render-0.3.0}/janction_render/mcp_server.py +115 -35
- {janction_render-0.2.0 → janction_render-0.3.0}/janction_render.egg-info/PKG-INFO +35 -16
- {janction_render-0.2.0 → janction_render-0.3.0}/janction_render.egg-info/requires.txt +3 -0
- {janction_render-0.2.0 → janction_render-0.3.0}/pyproject.toml +2 -1
- {janction_render-0.2.0 → janction_render-0.3.0}/LICENSE +0 -0
- {janction_render-0.2.0 → janction_render-0.3.0}/janction_render/cli.py +0 -0
- {janction_render-0.2.0 → janction_render-0.3.0}/janction_render.egg-info/SOURCES.txt +0 -0
- {janction_render-0.2.0 → janction_render-0.3.0}/janction_render.egg-info/dependency_links.txt +0 -0
- {janction_render-0.2.0 → janction_render-0.3.0}/janction_render.egg-info/entry_points.txt +0 -0
- {janction_render-0.2.0 → janction_render-0.3.0}/janction_render.egg-info/top_level.txt +0 -0
- {janction_render-0.2.0 → 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,13 +56,14 @@ 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
|
|
|
53
65
|
```
|
|
54
|
-
/plugin marketplace add
|
|
66
|
+
/plugin marketplace add JasmyLab-JANCTION/janction-render
|
|
55
67
|
/plugin install janction-render@janction-render
|
|
56
68
|
```
|
|
57
69
|
|
|
@@ -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
|
|
@@ -144,9 +163,9 @@ holds the client side: stdio MCP server, CLI, HTTP client, samples and the Claud
|
|
|
144
163
|
|
|
145
164
|
## MCP registry
|
|
146
165
|
|
|
147
|
-
This server is listed in the official MCP registry as `io.github.
|
|
166
|
+
This server is listed in the official MCP registry as `io.github.JasmyLab-JANCTION/janction-render` (remote: `https://render.janction.jp/mcp`).
|
|
148
167
|
|
|
149
|
-
mcp-name: io.github.
|
|
168
|
+
mcp-name: io.github.JasmyLab-JANCTION/janction-render
|
|
150
169
|
|
|
151
170
|
## License
|
|
152
171
|
|
|
@@ -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,13 +39,14 @@ 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
|
|
|
38
48
|
```
|
|
39
|
-
/plugin marketplace add
|
|
49
|
+
/plugin marketplace add JasmyLab-JANCTION/janction-render
|
|
40
50
|
/plugin install janction-render@janction-render
|
|
41
51
|
```
|
|
42
52
|
|
|
@@ -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
|
|
@@ -129,9 +146,9 @@ holds the client side: stdio MCP server, CLI, HTTP client, samples and the Claud
|
|
|
129
146
|
|
|
130
147
|
## MCP registry
|
|
131
148
|
|
|
132
|
-
This server is listed in the official MCP registry as `io.github.
|
|
149
|
+
This server is listed in the official MCP registry as `io.github.JasmyLab-JANCTION/janction-render` (remote: `https://render.janction.jp/mcp`).
|
|
133
150
|
|
|
134
|
-
mcp-name: io.github.
|
|
151
|
+
mcp-name: io.github.JasmyLab-JANCTION/janction-render
|
|
135
152
|
|
|
136
153
|
## License
|
|
137
154
|
|
|
@@ -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,10 +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.
|
|
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."
|
|
45
51
|
),
|
|
46
52
|
)
|
|
47
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
|
+
|
|
48
61
|
|
|
49
62
|
def _log(msg: str) -> None:
|
|
50
63
|
print(f"[janction-render] {msg}", file=sys.stderr, flush=True)
|
|
@@ -84,36 +97,78 @@ def _payment_block(exc: ClientError) -> Optional[dict[str, Any]]:
|
|
|
84
97
|
return p
|
|
85
98
|
|
|
86
99
|
|
|
87
|
-
|
|
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()
|
|
88
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 []]})
|
|
89
110
|
return scene_id
|
|
90
111
|
if scene_script.strip():
|
|
91
112
|
up = c.upload_text("scene.py", scene_script)
|
|
92
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 []]})
|
|
93
117
|
return up["scene_id"]
|
|
94
118
|
if not scene_path:
|
|
95
|
-
raise ValueError("pass scene_path (a .blend
|
|
96
|
-
"or scene_id from a previous call")
|
|
97
|
-
up = c.upload(scene_path)
|
|
98
|
-
_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
|
|
99
129
|
return up["scene_id"]
|
|
100
130
|
|
|
101
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
|
+
|
|
102
155
|
@mcp.tool()
|
|
103
|
-
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:
|
|
104
158
|
"""Read a Blender scene WITHOUT rendering: cameras (and which is active), frame range and fps,
|
|
105
159
|
resolution, engine, objects by type, lights, materials, whether it is animated, and any linked
|
|
106
160
|
files that are missing (textures etc. that would render pink). Takes a few seconds, no GPU.
|
|
107
161
|
|
|
108
|
-
Give the scene as scene_path (.blend
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
111
166
|
frame range in render_preview / render_final. Returns scene_id to reuse in the next calls.
|
|
112
167
|
"""
|
|
113
168
|
c = _client()
|
|
114
169
|
try:
|
|
115
|
-
sid = _resolve_scene(c, scene_path, scene_id, scene_script)
|
|
116
|
-
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))
|
|
117
172
|
if j["status"] != "done":
|
|
118
173
|
j = c.wait(j["job_id"], timeout=INFO_WAIT_S)
|
|
119
174
|
except (ClientError, ValueError, FileNotFoundError) as exc:
|
|
@@ -130,8 +185,11 @@ def scene_info(scene_path: str = "", scene_id: str = "", scene_script: str = "")
|
|
|
130
185
|
if j.get("warnings"):
|
|
131
186
|
info["blender_warnings"] = j["warnings"]
|
|
132
187
|
if info.get("missing_files"):
|
|
133
|
-
info["note"] = "missing_files will render pink/black;
|
|
134
|
-
|
|
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))
|
|
135
193
|
|
|
136
194
|
|
|
137
195
|
@mcp.tool(structured_output=False)
|
|
@@ -145,6 +203,11 @@ def render_preview(
|
|
|
145
203
|
height: int = 720,
|
|
146
204
|
samples: int = 16,
|
|
147
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,
|
|
148
211
|
) -> list[Any]:
|
|
149
212
|
"""Render a fast, cheap preview of a Blender scene on a JANCTION GPU and show the image.
|
|
150
213
|
|
|
@@ -158,13 +221,14 @@ def render_preview(
|
|
|
158
221
|
Look at the returned image, fix the scene, preview again; when it looks right, ask the user and
|
|
159
222
|
call render_final. Returns scene_id (reuse it without re-uploading), job_id, saved PNG paths,
|
|
160
223
|
Blender warnings (e.g. missing textures), GPU seconds, and the expiry time (24h after last use).
|
|
161
|
-
"""
|
|
224
|
+
""" + ENV_DOC
|
|
162
225
|
c = _client()
|
|
163
226
|
try:
|
|
164
227
|
frame_list = parse_frames(frames)
|
|
165
|
-
sid = _resolve_scene(c, scene_path, scene_id, scene_script)
|
|
228
|
+
sid = _resolve_scene(c, scene_path, scene_id, scene_script, assets)
|
|
166
229
|
j = c.submit(sid, kind="preview", frames=frame_list, camera=camera or None,
|
|
167
|
-
width=width, height=height, samples=samples
|
|
230
|
+
width=width, height=height, samples=samples,
|
|
231
|
+
**_job_params(environment, environment_strength, environment_visible, blender))
|
|
168
232
|
_log(f"preview job {j['job_id']} submitted frames={frame_list}")
|
|
169
233
|
j = c.wait(j["job_id"], timeout=PREVIEW_WAIT_S)
|
|
170
234
|
except ClientError as exc:
|
|
@@ -184,6 +248,7 @@ def render_preview(
|
|
|
184
248
|
brief["files"] = [str(p) for p in paths]
|
|
185
249
|
brief["next"] = ("look at the image; if the scene needs changes, edit it and call render_preview again; "
|
|
186
250
|
"if it looks right, ask the user before calling render_final")
|
|
251
|
+
_attach_upload_notes(brief)
|
|
187
252
|
result: list[Any] = [_j(brief)]
|
|
188
253
|
if show is not None:
|
|
189
254
|
result.append(Image(data=show.read_bytes(), format="png"))
|
|
@@ -234,22 +299,28 @@ def render_final(
|
|
|
234
299
|
fps: int = 24,
|
|
235
300
|
output: str = "auto",
|
|
236
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,
|
|
237
307
|
) -> str:
|
|
238
308
|
"""Render the final frames (or a video) of a Blender scene on JANCTION GPUs.
|
|
239
309
|
|
|
240
310
|
Call this after the user approved a preview. Pass scene_id from render_preview (or scene_path /
|
|
241
|
-
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
|
|
242
312
|
split across GPUs and joined into output.mp4 (output='mp4', fps), a single frame gives a PNG
|
|
243
|
-
(output='png'; 'auto' picks).
|
|
244
|
-
in the background: poll with render_status, then fetch with
|
|
245
|
-
results are deleted 24 hours after last use, so download them.
|
|
246
|
-
"""
|
|
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
|
|
247
317
|
c = _client()
|
|
248
318
|
try:
|
|
249
|
-
sid = _resolve_scene(c, scene_path, scene_id, scene_script)
|
|
319
|
+
sid = _resolve_scene(c, scene_path, scene_id, scene_script, assets)
|
|
250
320
|
j = c.submit(sid, kind="final", frame_start=frame_start, frame_end=frame_end,
|
|
251
321
|
width=width, height=height, samples=samples, fps=fps, output=output,
|
|
252
|
-
camera=camera or None
|
|
322
|
+
camera=camera or None,
|
|
323
|
+
**_job_params(environment, environment_strength, environment_visible, blender))
|
|
253
324
|
except ClientError as exc:
|
|
254
325
|
block = _payment_block(exc) or _quota_block(exc)
|
|
255
326
|
return _j(block if block else {"ok": False, "error": str(exc)})
|
|
@@ -258,8 +329,8 @@ def render_final(
|
|
|
258
329
|
brief = _brief(j)
|
|
259
330
|
est = j.get("estimate") or {}
|
|
260
331
|
brief["next"] = (f"tell the user it will take {est.get('human', 'a few minutes')}; then render_status(job_id) "
|
|
261
|
-
"until status is done, then render_download(job_id)")
|
|
262
|
-
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))
|
|
263
334
|
|
|
264
335
|
|
|
265
336
|
@mcp.tool()
|
|
@@ -279,10 +350,12 @@ def render_status(job_id: str) -> str:
|
|
|
279
350
|
|
|
280
351
|
|
|
281
352
|
@mcp.tool()
|
|
282
|
-
def render_download(job_id: str, out_dir: str = "", wait_seconds: int = 0) -> str:
|
|
283
|
-
"""Download a JANCTION render job's files
|
|
284
|
-
(default
|
|
285
|
-
|
|
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)."""
|
|
286
359
|
c = _client()
|
|
287
360
|
try:
|
|
288
361
|
j = c.wait(job_id, timeout=float(wait_seconds)) if wait_seconds > 0 else c.job(job_id)
|
|
@@ -291,11 +364,17 @@ def render_download(job_id: str, out_dir: str = "", wait_seconds: int = 0) -> st
|
|
|
291
364
|
b["hint"] = "nothing to download yet"
|
|
292
365
|
return _j(b)
|
|
293
366
|
out = Path(out_dir) if out_dir else _out_root() / job_id
|
|
294
|
-
paths = c.download(job_id, out)
|
|
295
|
-
except ClientError as exc:
|
|
367
|
+
paths = c.download(job_id, out, only=only)
|
|
368
|
+
except (ClientError, ValueError) as exc:
|
|
296
369
|
return _j({"ok": False, "error": str(exc)})
|
|
297
370
|
b = _brief(j)
|
|
298
|
-
|
|
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"
|
|
299
378
|
if j["status"] != "done":
|
|
300
379
|
b["hint"] = "job is not finished; these are the frames ready so far"
|
|
301
380
|
return _j(b)
|
|
@@ -355,6 +434,7 @@ def render_info() -> str:
|
|
|
355
434
|
except (ClientError, Exception) as exc: # noqa: BLE001
|
|
356
435
|
return _j({"ok": False, "server": c.server, "error": str(exc)})
|
|
357
436
|
return _j({"ok": True, "server": c.server, "workers": health["workers"], "queue": health["queue"],
|
|
437
|
+
"features": health.get("features"),
|
|
358
438
|
"key": me, "downloads_go_to": str(_out_root()),
|
|
359
439
|
"remote_mcp": f"{c.server}/mcp (Streamable HTTP; for Claude.ai, ChatGPT, Cursor: add this URL as a connector)"})
|
|
360
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,13 +56,14 @@ 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
|
|
|
53
65
|
```
|
|
54
|
-
/plugin marketplace add
|
|
66
|
+
/plugin marketplace add JasmyLab-JANCTION/janction-render
|
|
55
67
|
/plugin install janction-render@janction-render
|
|
56
68
|
```
|
|
57
69
|
|
|
@@ -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
|
|
@@ -144,9 +163,9 @@ holds the client side: stdio MCP server, CLI, HTTP client, samples and the Claud
|
|
|
144
163
|
|
|
145
164
|
## MCP registry
|
|
146
165
|
|
|
147
|
-
This server is listed in the official MCP registry as `io.github.
|
|
166
|
+
This server is listed in the official MCP registry as `io.github.JasmyLab-JANCTION/janction-render` (remote: `https://render.janction.jp/mcp`).
|
|
148
167
|
|
|
149
|
-
mcp-name: io.github.
|
|
168
|
+
mcp-name: io.github.JasmyLab-JANCTION/janction-render
|
|
150
169
|
|
|
151
170
|
## License
|
|
152
171
|
|
|
@@ -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.0 → 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
|