janction-render 0.2.1__tar.gz → 0.3.1__tar.gz

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