janction-render 0.3.2__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.
- {janction_render-0.3.2 → janction_render-0.3.4}/PKG-INFO +7 -2
- {janction_render-0.3.2 → janction_render-0.3.4}/README.md +6 -1
- {janction_render-0.3.2 → janction_render-0.3.4}/janction_render/__init__.py +1 -1
- janction_render-0.3.4/janction_render/mcp_app.html +157 -0
- {janction_render-0.3.2 → janction_render-0.3.4}/janction_render/mcp_server.py +66 -12
- {janction_render-0.3.2 → janction_render-0.3.4}/janction_render.egg-info/PKG-INFO +7 -2
- {janction_render-0.3.2 → janction_render-0.3.4}/janction_render.egg-info/SOURCES.txt +1 -0
- {janction_render-0.3.2 → janction_render-0.3.4}/pyproject.toml +4 -1
- {janction_render-0.3.2 → janction_render-0.3.4}/LICENSE +0 -0
- {janction_render-0.3.2 → janction_render-0.3.4}/janction_render/brief.py +0 -0
- {janction_render-0.3.2 → janction_render-0.3.4}/janction_render/cli.py +0 -0
- {janction_render-0.3.2 → janction_render-0.3.4}/janction_render/client.py +0 -0
- {janction_render-0.3.2 → janction_render-0.3.4}/janction_render.egg-info/dependency_links.txt +0 -0
- {janction_render-0.3.2 → janction_render-0.3.4}/janction_render.egg-info/entry_points.txt +0 -0
- {janction_render-0.3.2 → janction_render-0.3.4}/janction_render.egg-info/requires.txt +0 -0
- {janction_render-0.3.2 → janction_render-0.3.4}/janction_render.egg-info/top_level.txt +0 -0
- {janction_render-0.3.2 → janction_render-0.3.4}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: janction-render
|
|
3
|
-
Version: 0.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
|
|
@@ -29,8 +29,13 @@ without a GPU, or when rendering locally is slow.
|
|
|
29
29
|
`environment="compare"` previews the same frame under all four presets in one labelled image, so the agent can pick one.
|
|
30
30
|
- A `.gltf` or `.obj` brings its `.bin` / `.mtl` / textures along automatically (from the same folder or the same URL folder).
|
|
31
31
|
- `orbit=True` turns any scene or imported model into a turntable: an orbit camera circles it once (`orbit_frames`, default 24;
|
|
32
|
-
a preview shows 0/90/180/270 degrees, a final render without `frame_end` gives the whole turn as an MP4).
|
|
32
|
+
a preview shows 0/90/180/270 degrees, a final render without `frame_end` gives the whole turn as an MP4). Flat floors and
|
|
33
|
+
walls are ignored when framing; `orbit_target` (object name or `x,y,z`) and `orbit_distance` (multiplier) adjust the shot.
|
|
34
|
+
- While the GPU is lent to another workload, tools answer right away with the queued job and a note instead of waiting.
|
|
33
35
|
- While a job renders, `render_status` counts finished frames inside the running chunk, so the ETA updates every few seconds.
|
|
36
|
+
- In hosts that support MCP Apps (Claude web and desktop, among others), previews, progress and download links also appear
|
|
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.
|
|
34
39
|
- `scene_info` reads the scene without rendering (cameras, frame range, missing files).
|
|
35
40
|
- `render_preview` returns 1-4 low-cost frames tiled in one image within seconds, so the agent can look, fix, and repeat.
|
|
36
41
|
- `render_estimate` says how long a render will take ("about 3 minutes") and whether it fits today's free quota.
|
|
@@ -12,8 +12,13 @@ without a GPU, or when rendering locally is slow.
|
|
|
12
12
|
`environment="compare"` previews the same frame under all four presets in one labelled image, so the agent can pick one.
|
|
13
13
|
- A `.gltf` or `.obj` brings its `.bin` / `.mtl` / textures along automatically (from the same folder or the same URL folder).
|
|
14
14
|
- `orbit=True` turns any scene or imported model into a turntable: an orbit camera circles it once (`orbit_frames`, default 24;
|
|
15
|
-
a preview shows 0/90/180/270 degrees, a final render without `frame_end` gives the whole turn as an MP4).
|
|
15
|
+
a preview shows 0/90/180/270 degrees, a final render without `frame_end` gives the whole turn as an MP4). Flat floors and
|
|
16
|
+
walls are ignored when framing; `orbit_target` (object name or `x,y,z`) and `orbit_distance` (multiplier) adjust the shot.
|
|
17
|
+
- While the GPU is lent to another workload, tools answer right away with the queued job and a note instead of waiting.
|
|
16
18
|
- While a job renders, `render_status` counts finished frames inside the running chunk, so the ETA updates every few seconds.
|
|
19
|
+
- In hosts that support MCP Apps (Claude web and desktop, among others), previews, progress and download links also appear
|
|
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.
|
|
17
22
|
- `scene_info` reads the scene without rendering (cameras, frame range, missing files).
|
|
18
23
|
- `render_preview` returns 1-4 low-cost frames tiled in one image within seconds, so the agent can look, fix, and repeat.
|
|
19
24
|
- `render_estimate` says how long a render will take ("about 3 minutes") and whether it fits today's free quota.
|
|
@@ -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 => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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>
|
|
@@ -52,8 +52,26 @@ mcp = MCPServer(
|
|
|
52
52
|
)
|
|
53
53
|
|
|
54
54
|
ORBIT_DOC = (" orbit=True: turntable; an orbit camera circles the scene once over orbit_frames frames (default 24, "
|
|
55
|
-
"elevation orbit_elevation deg, default 18).
|
|
56
|
-
"
|
|
55
|
+
"elevation orbit_elevation deg, default 18). Flat floors/walls are ignored when framing; orbit_target = an "
|
|
56
|
+
"object name or 'x,y,z' centres on that, orbit_distance scales the distance (1.0). A preview without frames "
|
|
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
|
+
|
|
57
75
|
ENV_DOC = ("environment: '' keeps the scene's own world; 'studio' | 'sunset' | 'overcast' | 'night' replaces it with a "
|
|
58
76
|
"bundled HDRI (good first render for scenes without lighting work); 'compare' (render_preview only) renders the "
|
|
59
77
|
"first frame under all four presets in one 2x2 image with labels, so you can pick one; environment_strength scales it (1.0); "
|
|
@@ -75,6 +93,27 @@ def _out_root() -> Path:
|
|
|
75
93
|
return Path(os.environ.get("JANCTION_RENDER_OUT") or (Path.home() / "janction-render"))
|
|
76
94
|
|
|
77
95
|
|
|
96
|
+
def _wait_unless_gated(c: Client, job: dict[str, Any], timeout: float) -> dict[str, Any]:
|
|
97
|
+
"""仕事を待つ。ただし仕事を取れるワーカーが無い(GPU が貸し出し中)なら待たずに返し、hint で伝える。"""
|
|
98
|
+
if job["status"] in ("done", "failed", "canceled"):
|
|
99
|
+
return job
|
|
100
|
+
try:
|
|
101
|
+
q = c.health().get("queue") or {}
|
|
102
|
+
if int(q.get("workers_available") or 0) == 0 and job["status"] == "queued":
|
|
103
|
+
job["gated"] = True
|
|
104
|
+
return job
|
|
105
|
+
except Exception: # noqa: BLE001
|
|
106
|
+
pass
|
|
107
|
+
return c.wait(job["job_id"], timeout=timeout)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _gated_hint(j: dict[str, Any], again: str) -> str:
|
|
111
|
+
if j.get("gated"):
|
|
112
|
+
return (f"the GPU is lent to another workload right now; the job stays queued and starts when the GPU returns. "
|
|
113
|
+
f"Tell the user and offer to check later with {again}; do not poll continuously")
|
|
114
|
+
return ""
|
|
115
|
+
|
|
116
|
+
|
|
78
117
|
def _j(obj: Any) -> str:
|
|
79
118
|
return json.dumps(obj, ensure_ascii=False, indent=2)
|
|
80
119
|
|
|
@@ -174,12 +213,12 @@ def scene_info(scene_path: str = "", scene_id: str = "", scene_script: str = "",
|
|
|
174
213
|
sid = _resolve_scene(c, scene_path, scene_id, scene_script, assets)
|
|
175
214
|
j = c.submit(sid, kind="info", **_job_params("", 1.0, True, blender))
|
|
176
215
|
if j["status"] != "done":
|
|
177
|
-
j = c
|
|
216
|
+
j = _wait_unless_gated(c, j, INFO_WAIT_S)
|
|
178
217
|
except (ClientError, ValueError, FileNotFoundError) as exc:
|
|
179
218
|
return _j({"ok": False, "error": str(exc)})
|
|
180
219
|
if j["status"] != "done":
|
|
181
220
|
b = _brief(j)
|
|
182
|
-
b["hint"] = "still queued (a GPU worker may be busy); call scene_info again in a moment" \
|
|
221
|
+
b["hint"] = (_gated_hint(j, "scene_info(scene_id)") or "still queued (a GPU worker may be busy); call scene_info again in a moment") \
|
|
183
222
|
if j["status"] in ("queued", "running") else "fix the scene and try again"
|
|
184
223
|
return _j(b)
|
|
185
224
|
info = dict(j.get("info") or {})
|
|
@@ -196,7 +235,7 @@ def scene_info(scene_path: str = "", scene_id: str = "", scene_script: str = "",
|
|
|
196
235
|
return _j(_attach_upload_notes(info))
|
|
197
236
|
|
|
198
237
|
|
|
199
|
-
@mcp.tool(structured_output=False)
|
|
238
|
+
@mcp.tool(structured_output=False, meta=_ui_meta())
|
|
200
239
|
def render_preview(
|
|
201
240
|
scene_path: str = "",
|
|
202
241
|
scene_id: str = "",
|
|
@@ -215,6 +254,8 @@ def render_preview(
|
|
|
215
254
|
orbit: bool = False,
|
|
216
255
|
orbit_frames: int = 24,
|
|
217
256
|
orbit_elevation: float = 18.0,
|
|
257
|
+
orbit_target: str = "",
|
|
258
|
+
orbit_distance: float = 1.0,
|
|
218
259
|
) -> list[Any]:
|
|
219
260
|
"""Render a fast, cheap preview of a Blender scene on a JANCTION GPU and show the image.
|
|
220
261
|
|
|
@@ -236,9 +277,10 @@ def render_preview(
|
|
|
236
277
|
j = c.submit(sid, kind="preview", frames=frame_list, camera=camera or None,
|
|
237
278
|
width=width, height=height, samples=samples,
|
|
238
279
|
**_job_params(environment, environment_strength, environment_visible, blender),
|
|
239
|
-
**({"orbit": True, "orbit_frames": orbit_frames, "orbit_elevation": orbit_elevation
|
|
280
|
+
**({"orbit": True, "orbit_frames": orbit_frames, "orbit_elevation": orbit_elevation,
|
|
281
|
+
"orbit_target": orbit_target, "orbit_distance": orbit_distance} if orbit else {}))
|
|
240
282
|
_log(f"preview job {j['job_id']} submitted frames={frame_list}")
|
|
241
|
-
j = c
|
|
283
|
+
j = _wait_unless_gated(c, j, PREVIEW_WAIT_S)
|
|
242
284
|
except ClientError as exc:
|
|
243
285
|
block = _payment_block(exc) or _quota_block(exc)
|
|
244
286
|
return [_j(block if block else {"ok": False, "error": str(exc)})]
|
|
@@ -246,7 +288,7 @@ def render_preview(
|
|
|
246
288
|
return [_j({"ok": False, "error": str(exc)})]
|
|
247
289
|
brief = _brief(j)
|
|
248
290
|
if j["status"] != "done":
|
|
249
|
-
brief["hint"] = ("still running: call render_status(job_id) and then render_download"
|
|
291
|
+
brief["hint"] = ((_gated_hint(j, "render_status(job_id)") or "still running: call render_status(job_id) and then render_download")
|
|
250
292
|
if j["status"] in ("queued", "running")
|
|
251
293
|
else "fix the scene and try again")
|
|
252
294
|
return [_j(brief)]
|
|
@@ -294,7 +336,7 @@ def render_estimate(scene_id: str = "", kind: str = "final", frame_start: int =
|
|
|
294
336
|
return _j(out)
|
|
295
337
|
|
|
296
338
|
|
|
297
|
-
@mcp.tool()
|
|
339
|
+
@mcp.tool(meta=_ui_meta())
|
|
298
340
|
def render_final(
|
|
299
341
|
scene_path: str = "",
|
|
300
342
|
scene_id: str = "",
|
|
@@ -315,6 +357,8 @@ def render_final(
|
|
|
315
357
|
orbit: bool = False,
|
|
316
358
|
orbit_frames: int = 24,
|
|
317
359
|
orbit_elevation: float = 18.0,
|
|
360
|
+
orbit_target: str = "",
|
|
361
|
+
orbit_distance: float = 1.0,
|
|
318
362
|
) -> str:
|
|
319
363
|
"""Render the final frames (or a video) of a Blender scene on JANCTION GPUs.
|
|
320
364
|
|
|
@@ -330,7 +374,8 @@ def render_final(
|
|
|
330
374
|
sid = _resolve_scene(c, scene_path, scene_id, scene_script, assets)
|
|
331
375
|
extra: dict[str, Any] = {}
|
|
332
376
|
if orbit:
|
|
333
|
-
extra.update({"orbit": True, "orbit_frames": orbit_frames, "orbit_elevation": orbit_elevation
|
|
377
|
+
extra.update({"orbit": True, "orbit_frames": orbit_frames, "orbit_elevation": orbit_elevation,
|
|
378
|
+
"orbit_target": orbit_target, "orbit_distance": orbit_distance})
|
|
334
379
|
if frame_end <= frame_start:
|
|
335
380
|
frame_end = None # type: ignore[assignment] # 1 周ぶんは受付が決める
|
|
336
381
|
j = c.submit(sid, kind="final", frame_start=frame_start, frame_end=frame_end,
|
|
@@ -349,7 +394,7 @@ def render_final(
|
|
|
349
394
|
return _j(_attach_upload_notes(brief))
|
|
350
395
|
|
|
351
396
|
|
|
352
|
-
@mcp.tool()
|
|
397
|
+
@mcp.tool(meta=_ui_meta())
|
|
353
398
|
def render_status(job_id: str) -> str:
|
|
354
399
|
"""Check a JANCTION render job: status (queued/running/done/failed/canceled), frames done,
|
|
355
400
|
how many chunks are queued ahead, the ETA (eta.human = remaining time including queue wait),
|
|
@@ -365,7 +410,7 @@ def render_status(job_id: str) -> str:
|
|
|
365
410
|
return _j(b)
|
|
366
411
|
|
|
367
412
|
|
|
368
|
-
@mcp.tool()
|
|
413
|
+
@mcp.tool(meta=_ui_meta())
|
|
369
414
|
def render_download(job_id: str, out_dir: str = "", wait_seconds: int = 0, only: str = "all") -> str:
|
|
370
415
|
"""Download a JANCTION render job's files to out_dir (default ~/janction-render/<job_id>).
|
|
371
416
|
only: 'all' (default: every PNG frame plus output.mp4 / sheet.png), 'mp4' (just the video), 'frames'
|
|
@@ -455,6 +500,15 @@ def render_info() -> str:
|
|
|
455
500
|
"remote_mcp": f"{c.server}/mcp (Streamable HTTP; for Claude.ai, ChatGPT, Cursor: add this URL as a connector)"})
|
|
456
501
|
|
|
457
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
|
+
|
|
458
512
|
def main() -> None:
|
|
459
513
|
_log(f"stdio, server={Client().server}")
|
|
460
514
|
mcp.run()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: janction-render
|
|
3
|
-
Version: 0.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
|
|
@@ -29,8 +29,13 @@ without a GPU, or when rendering locally is slow.
|
|
|
29
29
|
`environment="compare"` previews the same frame under all four presets in one labelled image, so the agent can pick one.
|
|
30
30
|
- A `.gltf` or `.obj` brings its `.bin` / `.mtl` / textures along automatically (from the same folder or the same URL folder).
|
|
31
31
|
- `orbit=True` turns any scene or imported model into a turntable: an orbit camera circles it once (`orbit_frames`, default 24;
|
|
32
|
-
a preview shows 0/90/180/270 degrees, a final render without `frame_end` gives the whole turn as an MP4).
|
|
32
|
+
a preview shows 0/90/180/270 degrees, a final render without `frame_end` gives the whole turn as an MP4). Flat floors and
|
|
33
|
+
walls are ignored when framing; `orbit_target` (object name or `x,y,z`) and `orbit_distance` (multiplier) adjust the shot.
|
|
34
|
+
- While the GPU is lent to another workload, tools answer right away with the queued job and a note instead of waiting.
|
|
33
35
|
- While a job renders, `render_status` counts finished frames inside the running chunk, so the ETA updates every few seconds.
|
|
36
|
+
- In hosts that support MCP Apps (Claude web and desktop, among others), previews, progress and download links also appear
|
|
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.
|
|
34
39
|
- `scene_info` reads the scene without rendering (cameras, frame range, missing files).
|
|
35
40
|
- `render_preview` returns 1-4 low-cost frames tiled in one image within seconds, so the agent can look, fix, and repeat.
|
|
36
41
|
- `render_estimate` says how long a render will take ("about 3 minutes") and whether it fits today's free quota.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "janction-render"
|
|
7
|
-
version = "0.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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{janction_render-0.3.2 → janction_render-0.3.4}/janction_render.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|