janction-render 0.3.3__tar.gz → 0.3.4__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.3.3
3
+ Version: 0.3.4
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
@@ -35,6 +35,7 @@ without a GPU, or when rendering locally is slow.
35
35
  - While a job renders, `render_status` counts finished frames inside the running chunk, so the ETA updates every few seconds.
36
36
  - In hosts that support MCP Apps (Claude web and desktop, among others), previews, progress and download links also appear
37
37
  as an interactive panel in the chat: the image, a progress bar, "Open MP4", and preset buttons after an `environment="compare"` preview.
38
+ Both the remote connector and the stdio package ship the panel (`janction_render/mcp_app.html`); set `JR_MCP_APPS=0` to turn it off.
38
39
  - `scene_info` reads the scene without rendering (cameras, frame range, missing files).
39
40
  - `render_preview` returns 1-4 low-cost frames tiled in one image within seconds, so the agent can look, fix, and repeat.
40
41
  - `render_estimate` says how long a render will take ("about 3 minutes") and whether it fits today's free quota.
@@ -18,6 +18,7 @@ without a GPU, or when rendering locally is slow.
18
18
  - While a job renders, `render_status` counts finished frames inside the running chunk, so the ETA updates every few seconds.
19
19
  - In hosts that support MCP Apps (Claude web and desktop, among others), previews, progress and download links also appear
20
20
  as an interactive panel in the chat: the image, a progress bar, "Open MP4", and preset buttons after an `environment="compare"` preview.
21
+ Both the remote connector and the stdio package ship the panel (`janction_render/mcp_app.html`); set `JR_MCP_APPS=0` to turn it off.
21
22
  - `scene_info` reads the scene without rendering (cameras, frame range, missing files).
22
23
  - `render_preview` returns 1-4 low-cost frames tiled in one image within seconds, so the agent can look, fix, and repeat.
23
24
  - `render_estimate` says how long a render will take ("about 3 minutes") and whether it fits today's free quota.
@@ -1,3 +1,3 @@
1
1
  """JANCTION Render — ターミナルの AI エージェントから GPU で 3DCG を描く。"""
2
2
 
3
- __version__ = "0.3.3"
3
+ __version__ = "0.3.4"
@@ -0,0 +1,157 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="color-scheme" content="light dark">
7
+ <title>JANCTION Render</title>
8
+ <style>
9
+ :root { color-scheme: light dark; --fg: #1b1b1f; --muted: #5f6368; --line: #d9dbe0; --bg: #ffffff; --accent: #1f3a8a; --ok: #1a7f37; --warn: #b26a00; --bad: #b3261e; }
10
+ @media (prefers-color-scheme: dark) { :root { --fg: #e8eaed; --muted: #9aa0a6; --line: #3c4043; --bg: #1b1b1f; --accent: #8ab4f8; --ok: #6ddb8a; --warn: #f2b84b; --bad: #f28b82; } }
11
+ html, body { margin: 0; padding: 0; background: transparent; color: var(--fg); font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
12
+ .card { padding: 12px 14px; }
13
+ .head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
14
+ .kind { font-weight: 600; }
15
+ .pill { font-size: 12px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--line); color: var(--muted); }
16
+ .pill.done { color: var(--ok); border-color: var(--ok); }
17
+ .pill.run { color: var(--accent); border-color: var(--accent); }
18
+ .pill.bad { color: var(--bad); border-color: var(--bad); }
19
+ .muted { color: var(--muted); font-size: 12px; }
20
+ img.sheet { display: block; max-width: 100%; height: auto; border-radius: 8px; margin: 10px 0 6px; border: 1px solid var(--line); }
21
+ video { display: block; max-width: 100%; border-radius: 8px; margin: 10px 0 6px; border: 1px solid var(--line); }
22
+ .bar { height: 8px; background: var(--line); border-radius: 4px; overflow: hidden; margin: 8px 0 4px; }
23
+ .bar > i { display: block; height: 100%; width: 0; background: var(--accent); transition: width .4s; }
24
+ .row { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
25
+ button { font: inherit; font-size: 13px; padding: 6px 12px; border-radius: 6px; border: 1px solid var(--accent); background: transparent; color: var(--accent); cursor: pointer; }
26
+ button.primary { background: var(--accent); color: #fff; }
27
+ button:disabled { opacity: .5; cursor: default; }
28
+ details { margin-top: 8px; } summary { cursor: pointer; color: var(--muted); font-size: 12px; }
29
+ ul.warn { margin: 4px 0 0 18px; padding: 0; font-size: 12px; color: var(--warn); }
30
+ .err { color: var(--bad); white-space: pre-wrap; font-size: 12px; }
31
+ .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-top: 8px; }
32
+ </style>
33
+ </head>
34
+ <body>
35
+ <div class="card" id="root"><span class="muted">JANCTION Render</span></div>
36
+ <script type="module">
37
+ import { App } from "https://unpkg.com/@modelcontextprotocol/ext-apps@0.4.0/app-with-deps";
38
+
39
+ const root = document.getElementById("root");
40
+ const app = new App({ name: "JANCTION Render", version: "1.0.0" });
41
+ let state = { job: null, image: null, toolName: "", pollTimer: null, polls: 0 };
42
+ const PRESETS = ["studio", "sunset", "overcast", "night"];
43
+
44
+ function parseResult(result) {
45
+ const content = result?.content || [];
46
+ const text = content.find(c => c.type === "text")?.text || "";
47
+ let job = null;
48
+ try { job = JSON.parse(text); } catch (e) { job = { raw: text }; }
49
+ const img = content.find(c => c.type === "image");
50
+ return { job, image: img ? `data:${img.mimeType || "image/png"};base64,${img.data}` : null };
51
+ }
52
+
53
+ function esc(s) { return String(s ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c])); }
54
+
55
+ function progressOf(job) {
56
+ const m = /(\d+)\/(\d+) frames/.exec(job?.progress || "");
57
+ if (!m) return null;
58
+ const done = +m[1], total = +m[2];
59
+ return { done, total, pct: total ? Math.round(100 * done / total) : 0 };
60
+ }
61
+
62
+ function isCompare(job) {
63
+ const w = job?.warnings || [];
64
+ return w.some(x => String(x).startsWith("compare:"));
65
+ }
66
+
67
+ function render() {
68
+ const job = state.job;
69
+ if (!job) { root.innerHTML = '<span class="muted">JANCTION Render</span>'; return; }
70
+ if (job.ok === false || job.error && !job.status) {
71
+ root.innerHTML = `<div class="head"><span class="kind">JANCTION Render</span><span class="pill bad">error</span></div><div class="err">${esc(job.error || job.detail || "")}\n${esc(job.detail || "")}</div>`;
72
+ return;
73
+ }
74
+ const status = job.status || "";
75
+ const pillClass = status === "done" ? "done" : (status === "failed" || status === "canceled") ? "bad" : "run";
76
+ const kind = job.kind === "preview" ? "Preview" : job.kind === "final" ? "Final render" : job.kind === "info" ? "Scene info" : "Render";
77
+ let html = `<div class="head"><span class="kind">${esc(kind)}</span><span class="pill ${pillClass}">${esc(status || "ready")}</span>`;
78
+ if (job.frames) html += `<span class="muted">frames ${esc(job.frames)}</span>`;
79
+ if (job.gpu_seconds) html += `<span class="muted">${esc(job.gpu_seconds)} GPU s</span>`;
80
+ if (job.device) html += `<span class="muted">${esc(job.device)}</span>`;
81
+ html += `</div>`;
82
+ const eta = job.eta?.human || job.estimate?.human;
83
+ if (status === "queued" || status === "running") {
84
+ const p = progressOf(job);
85
+ if (p) html += `<div class="bar"><i style="width:${p.pct}%"></i></div><div class="muted">${p.done}/${p.total} frames${eta ? " · " + esc(eta) : ""}</div>`;
86
+ else if (eta) html += `<div class="muted">${esc(eta)}</div>`;
87
+ } else if (eta && status === "done") {
88
+ html += `<div class="muted">${esc(eta)}</div>`;
89
+ }
90
+ if (state.image) html += `<img class="sheet" src="${state.image}" alt="render preview">`;
91
+ const out = job.output_url;
92
+ const links = job.links || {};
93
+ if (status === "done" && out && /\.mp4(\?|$)/.test(out)) {
94
+ html += `<video controls playsinline src="${esc(out)}"></video>`;
95
+ }
96
+ if (status === "done" && isCompare(job)) {
97
+ html += `<div class="muted">Pick the lighting for the final render:</div><div class="grid">` +
98
+ PRESETS.map(p => `<button data-preset="${p}">Use ${p}</button>`).join("") + `</div>`;
99
+ }
100
+ html += `<div class="row">`;
101
+ if (status === "queued" || status === "running") html += `<button data-act="refresh">Refresh</button>`;
102
+ if (status === "done" && out) html += `<button class="primary" data-open="${esc(out)}">Open ${/\.mp4/.test(out) ? "MP4" : "image"}</button>`;
103
+ const frameLinks = Object.entries(links).filter(([n]) => n.startsWith("frame_"));
104
+ if (status === "done" && frameLinks.length) html += `<button data-open="${esc(frameLinks[0][1])}">Open first frame</button>`;
105
+ if (status === "done" && job.kind === "preview") html += `<button data-say="This preview looks right. Please ask me about frames, resolution and samples, then render the final.">Looks good, go final</button><button data-say="Please adjust the scene and preview again.">Needs changes</button>`;
106
+ html += `</div>`;
107
+ const warns = (job.warnings || []).filter(w => !String(w).startsWith("compare:"));
108
+ if (warns.length) html += `<details><summary>${warns.length} note${warns.length > 1 ? "s" : ""} from Blender</summary><ul class="warn">${warns.map(w => `<li>${esc(w)}</li>`).join("")}</ul></details>`;
109
+ if (job.expires_at) html += `<div class="muted">files kept until ${esc(job.expires_at)}</div>`;
110
+ root.innerHTML = html;
111
+ root.querySelectorAll("button[data-open]").forEach(b => b.addEventListener("click", () => app.openLink({ url: b.dataset.open })));
112
+ root.querySelectorAll("button[data-say]").forEach(b => b.addEventListener("click", () => app.sendMessage({ role: "user", content: { type: "text", text: b.dataset.say } })));
113
+ root.querySelectorAll("button[data-preset]").forEach(b => b.addEventListener("click", () => app.sendMessage({ role: "user", content: { type: "text", text: `Use the ${b.dataset.preset} lighting preset (environment="${b.dataset.preset}") for the final render. Ask me about frames, resolution and samples first.` } })));
114
+ root.querySelector("button[data-act=refresh]")?.addEventListener("click", refresh);
115
+ schedulePoll();
116
+ }
117
+
118
+ async function refresh() {
119
+ const id = state.job?.job_id;
120
+ if (!id) return;
121
+ try {
122
+ const r = await app.callServerTool({ name: "render_status", arguments: { job_id: id } });
123
+ const parsed = parseResult(r);
124
+ if (parsed.job && parsed.job.job_id) state.job = parsed.job;
125
+ if (parsed.image) state.image = parsed.image;
126
+ if (state.job.status === "done" && !state.image && state.job.kind === "preview") {
127
+ const d = await app.callServerTool({ name: "render_download", arguments: { job_id: id } });
128
+ const p2 = parseResult(d);
129
+ if (p2.image) state.image = p2.image;
130
+ if (p2.job && p2.job.job_id) state.job = p2.job;
131
+ }
132
+ render();
133
+ } catch (e) { console.error(e); }
134
+ }
135
+
136
+ function schedulePoll() {
137
+ if (state.pollTimer) { clearTimeout(state.pollTimer); state.pollTimer = null; }
138
+ const s = state.job?.status;
139
+ if ((s === "queued" || s === "running") && state.polls < 120) {
140
+ state.polls += 1;
141
+ state.pollTimer = setTimeout(refresh, 5000);
142
+ }
143
+ }
144
+
145
+ app.ontoolresult = (result) => {
146
+ const parsed = parseResult(result);
147
+ state.job = parsed.job; state.image = parsed.image; state.polls = 0;
148
+ render();
149
+ };
150
+ app.ontoolinput = () => { root.innerHTML = '<span class="muted">Rendering on JANCTION…</span>'; };
151
+
152
+ await app.connect();
153
+ const ctx = app.getHostContext?.();
154
+ state.toolName = ctx?.toolInfo?.tool?.name || "";
155
+ </script>
156
+ </body>
157
+ </html>
@@ -55,6 +55,23 @@ ORBIT_DOC = (" orbit=True: turntable; an orbit camera circles the scene once ove
55
55
  "elevation orbit_elevation deg, default 18). Flat floors/walls are ignored when framing; orbit_target = an "
56
56
  "object name or 'x,y,z' centres on that, orbit_distance scales the distance (1.0). A preview without frames "
57
57
  "shows 0/90/180/270 deg; a final without frame_end renders the whole turn as an MP4. Best for imported models.")
58
+ # ---- MCP Apps(Claude Desktop など対応ホストに、絵と進み具合のパネルを出す。対応していないホストは無視する)
59
+ UI_URI = "ui://janction-render/app.html"
60
+ UI_MIME = "text/html;profile=mcp-app"
61
+
62
+
63
+ def ui_enabled() -> bool:
64
+ return (os.environ.get("JR_MCP_APPS") or "1").strip().lower() not in ("0", "false", "no", "off")
65
+
66
+
67
+ def _ui_meta() -> Optional[dict[str, Any]]:
68
+ return {"ui": {"resourceUri": UI_URI}, "ui/resourceUri": UI_URI} if ui_enabled() else None
69
+
70
+
71
+ def ui_html() -> str:
72
+ return (Path(__file__).resolve().parent / "mcp_app.html").read_text(encoding="utf-8")
73
+
74
+
58
75
  ENV_DOC = ("environment: '' keeps the scene's own world; 'studio' | 'sunset' | 'overcast' | 'night' replaces it with a "
59
76
  "bundled HDRI (good first render for scenes without lighting work); 'compare' (render_preview only) renders the "
60
77
  "first frame under all four presets in one 2x2 image with labels, so you can pick one; environment_strength scales it (1.0); "
@@ -218,7 +235,7 @@ def scene_info(scene_path: str = "", scene_id: str = "", scene_script: str = "",
218
235
  return _j(_attach_upload_notes(info))
219
236
 
220
237
 
221
- @mcp.tool(structured_output=False)
238
+ @mcp.tool(structured_output=False, meta=_ui_meta())
222
239
  def render_preview(
223
240
  scene_path: str = "",
224
241
  scene_id: str = "",
@@ -319,7 +336,7 @@ def render_estimate(scene_id: str = "", kind: str = "final", frame_start: int =
319
336
  return _j(out)
320
337
 
321
338
 
322
- @mcp.tool()
339
+ @mcp.tool(meta=_ui_meta())
323
340
  def render_final(
324
341
  scene_path: str = "",
325
342
  scene_id: str = "",
@@ -377,7 +394,7 @@ def render_final(
377
394
  return _j(_attach_upload_notes(brief))
378
395
 
379
396
 
380
- @mcp.tool()
397
+ @mcp.tool(meta=_ui_meta())
381
398
  def render_status(job_id: str) -> str:
382
399
  """Check a JANCTION render job: status (queued/running/done/failed/canceled), frames done,
383
400
  how many chunks are queued ahead, the ETA (eta.human = remaining time including queue wait),
@@ -393,7 +410,7 @@ def render_status(job_id: str) -> str:
393
410
  return _j(b)
394
411
 
395
412
 
396
- @mcp.tool()
413
+ @mcp.tool(meta=_ui_meta())
397
414
  def render_download(job_id: str, out_dir: str = "", wait_seconds: int = 0, only: str = "all") -> str:
398
415
  """Download a JANCTION render job's files to out_dir (default ~/janction-render/<job_id>).
399
416
  only: 'all' (default: every PNG frame plus output.mp4 / sheet.png), 'mp4' (just the video), 'frames'
@@ -483,6 +500,15 @@ def render_info() -> str:
483
500
  "remote_mcp": f"{c.server}/mcp (Streamable HTTP; for Claude.ai, ChatGPT, Cursor: add this URL as a connector)"})
484
501
 
485
502
 
503
+ if ui_enabled():
504
+ @mcp.resource(UI_URI, name="JANCTION Render app", title="JANCTION Render", mime_type=UI_MIME,
505
+ description="Interactive view for JANCTION Render results: preview image, progress, ETA, links, preset picker.",
506
+ meta={"ui": {"csp": {"resourceDomains": ["https://unpkg.com", "https://render.janction.jp"], "connectDomains": []},
507
+ "prefersBorder": True}})
508
+ def janction_render_app() -> str:
509
+ return ui_html()
510
+
511
+
486
512
  def main() -> None:
487
513
  _log(f"stdio, server={Client().server}")
488
514
  mcp.run()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: janction-render
3
- Version: 0.3.3
3
+ Version: 0.3.4
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
@@ -35,6 +35,7 @@ without a GPU, or when rendering locally is slow.
35
35
  - While a job renders, `render_status` counts finished frames inside the running chunk, so the ETA updates every few seconds.
36
36
  - In hosts that support MCP Apps (Claude web and desktop, among others), previews, progress and download links also appear
37
37
  as an interactive panel in the chat: the image, a progress bar, "Open MP4", and preset buttons after an `environment="compare"` preview.
38
+ Both the remote connector and the stdio package ship the panel (`janction_render/mcp_app.html`); set `JR_MCP_APPS=0` to turn it off.
38
39
  - `scene_info` reads the scene without rendering (cameras, frame range, missing files).
39
40
  - `render_preview` returns 1-4 low-cost frames tiled in one image within seconds, so the agent can look, fix, and repeat.
40
41
  - `render_estimate` says how long a render will take ("about 3 minutes") and whether it fits today's free quota.
@@ -5,6 +5,7 @@ janction_render/__init__.py
5
5
  janction_render/brief.py
6
6
  janction_render/cli.py
7
7
  janction_render/client.py
8
+ janction_render/mcp_app.html
8
9
  janction_render/mcp_server.py
9
10
  janction_render.egg-info/PKG-INFO
10
11
  janction_render.egg-info/SOURCES.txt
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "janction-render"
7
- version = "0.3.3"
7
+ version = "0.3.4"
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"
@@ -21,3 +21,6 @@ janction-render-mcp = "janction_render.mcp_server:main"
21
21
 
22
22
  [tool.setuptools.packages.find]
23
23
  include = ["janction_render*"]
24
+
25
+ [tool.setuptools.package-data]
26
+ janction_render = ["mcp_app.html"]
File without changes