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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: janction-render
3
- Version: 0.2.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
+ [![JasmyLab-JANCTION/janction-render MCP server](https://glama.ai/mcp/servers/JasmyLab-JANCTION/janction-render/badges/score.svg)](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, or a **bpy Python script that builds the scene** (no local Blender needed).
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 `.py`) or `scene_id`;
49
- results come back as an inline image plus download links that need no key and work for about 24 hours.
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 KJdayo/janction-render
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 and expected wait |
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.KJdayo/janction-render` (remote: `https://render.janction.jp/mcp`).
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.KJdayo/janction-render
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
+ [![JasmyLab-JANCTION/janction-render MCP server](https://glama.ai/mcp/servers/JasmyLab-JANCTION/janction-render/badges/score.svg)](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, or a **bpy Python script that builds the scene** (no local Blender needed).
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 `.py`) or `scene_id`;
34
- results come back as an inline image plus download links that need no key and work for about 24 hours.
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 KJdayo/janction-render
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 and expected wait |
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.KJdayo/janction-render` (remote: `https://render.janction.jp/mcp`).
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.KJdayo/janction-render
151
+ mcp-name: io.github.JasmyLab-JANCTION/janction-render
135
152
 
136
153
  ## License
137
154
 
@@ -1,3 +1,3 @@
1
1
  """JANCTION Render — ターミナルの AI エージェントから GPU で 3DCG を描く。"""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.0"
@@ -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["expires_at"],
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 upload(self, path: str | Path) -> dict[str, Any]:
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 (".blend", ".py"):
163
- raise ValueError("scene must be a .blend file or a Blender Python script (.py)")
164
- with p.open("rb") as fh:
165
- r = self.s.post(f"{self.server}/v1/files", headers=self._headers(),
166
- files={"file": (p.name, fh, "application/octet-stream")}, timeout=1800)
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
- for a in j["artifacts"]:
242
- if only and a["name"] not in only:
243
- continue
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. Inputs and results are deleted 24 hours after last use."
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
- def _resolve_scene(c: Client, scene_path: str, scene_id: str, scene_script: str = "") -> str:
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 or a bpy .py script), scene_script (bpy code as text), "
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 = "") -> 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 or .py file), scene_script (bpy Python code as text, no
109
- file needed), or scene_id from a previous call. Call this first for a .blend you did not write
110
- yourself, or after writing a bpy script to check what it produced. Use the camera names and
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; pack them into the .blend (File > External Data > Pack) or fix the paths"
134
- return _j(info)
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). Returns job_id and a time estimate right away; the render runs
244
- in the background: poll with render_status, then fetch with render_download. Inputs and
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 (PNG frames, sheet.png and/or output.mp4) to out_dir
284
- (default ~/janction-render/<job_id>). wait_seconds > 0 first waits up to that long for the job
285
- to finish. Returns the local file paths."""
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
- b["files"] = [str(p) for p in paths]
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.2.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
+ [![JasmyLab-JANCTION/janction-render MCP server](https://glama.ai/mcp/servers/JasmyLab-JANCTION/janction-render/badges/score.svg)](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, or a **bpy Python script that builds the scene** (no local Blender needed).
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 `.py`) or `scene_id`;
49
- results come back as an inline image plus download links that need no key and work for about 24 hours.
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 KJdayo/janction-render
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 and expected wait |
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.KJdayo/janction-render` (remote: `https://render.janction.jp/mcp`).
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.KJdayo/janction-render
168
+ mcp-name: io.github.JasmyLab-JANCTION/janction-render
150
169
 
151
170
  ## License
152
171
 
@@ -1,6 +1,9 @@
1
1
  requests>=2.31
2
2
  mcp>=1.10
3
3
 
4
+ [blend]
5
+ blender-asset-tracer>=1.20
6
+
4
7
  [dev]
5
8
  pytest>=8
6
9
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "janction-render"
7
- version = "0.2.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