keyshot-mcp 0.8.0 → 0.9.0
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.
- package/README.md +56 -23
- package/dist/cli/status.js +2 -2
- package/dist/config.js +5 -1
- package/dist/diagnostics.js +191 -0
- package/dist/index.js +7 -5
- package/dist/output-collisions.js +43 -0
- package/dist/product-render.js +11 -2
- package/dist/quality.js +24 -0
- package/dist/result.js +77 -0
- package/dist/runner.js +8 -4
- package/dist/schemas.js +6 -0
- package/dist/version.js +1 -1
- package/package.json +2 -2
- package/scripts/keyshot-smoke.mjs +38 -20
- package/scripts/keyshot_bridge.py +1 -0
- package/server.json +2 -2
package/README.md
CHANGED
|
@@ -41,7 +41,7 @@ the local computer.
|
|
|
41
41
|
|
|
42
42
|
### Install
|
|
43
43
|
|
|
44
|
-
The current release is `0.
|
|
44
|
+
The current release is `0.9.0`.
|
|
45
45
|
|
|
46
46
|
#### Option 1: run with npx
|
|
47
47
|
|
|
@@ -52,7 +52,7 @@ This requires no global npm installation:
|
|
|
52
52
|
"mcpServers": {
|
|
53
53
|
"keyshot": {
|
|
54
54
|
"command": "npx",
|
|
55
|
-
"args": ["-y", "keyshot-mcp@0.
|
|
55
|
+
"args": ["-y", "keyshot-mcp@0.9.0"],
|
|
56
56
|
"env": {
|
|
57
57
|
"KEYSHOT_HEADLESS_EXE": "C:/Program Files/KeyShot Studio/bin/keyshot_headless.exe"
|
|
58
58
|
}
|
|
@@ -64,7 +64,7 @@ This requires no global npm installation:
|
|
|
64
64
|
#### Option 2: install globally
|
|
65
65
|
|
|
66
66
|
```bash
|
|
67
|
-
npm install -g keyshot-mcp@0.
|
|
67
|
+
npm install -g keyshot-mcp@0.9.0
|
|
68
68
|
```
|
|
69
69
|
|
|
70
70
|
```json
|
|
@@ -113,11 +113,11 @@ The following prompt can be pasted into a coding agent that is allowed to edit
|
|
|
113
113
|
the MCP client configuration:
|
|
114
114
|
|
|
115
115
|
```text
|
|
116
|
-
Install KeyShot MCP 0.
|
|
116
|
+
Install KeyShot MCP 0.9.0 and configure it in my MCP client.
|
|
117
117
|
|
|
118
118
|
1. Find the local KeyShot headless executable.
|
|
119
119
|
2. Add an MCP server named "keyshot" that runs:
|
|
120
|
-
npx -y keyshot-mcp@0.
|
|
120
|
+
npx -y keyshot-mcp@0.9.0
|
|
121
121
|
3. Set KEYSHOT_HEADLESS_EXE to the executable path.
|
|
122
122
|
4. Keep KEYSHOT_ALLOW_EXTERNAL_OUTPUTS disabled.
|
|
123
123
|
5. Restart or reload the MCP client, call keyshot_status, then use keyshot_product_render for a one-click product render.
|
|
@@ -129,7 +129,7 @@ Install KeyShot MCP 0.8.0 and configure it in my MCP client.
|
|
|
129
129
|
| Variable | Default | Purpose |
|
|
130
130
|
| --- | --- | --- |
|
|
131
131
|
| `KEYSHOT_HEADLESS_EXE` | `keyshot_headless.exe` on Windows | Absolute executable path or a command available on `PATH`. |
|
|
132
|
-
| `KEYSHOT_OUTPUT_DIR` |
|
|
132
|
+
| `KEYSHOT_OUTPUT_DIR` | `~/Documents/KeyShot MCP Outputs` | Root directory for rendered images and saved scenes. |
|
|
133
133
|
| `KEYSHOT_ALLOW_EXTERNAL_OUTPUTS` | `false` | Allows output paths outside `KEYSHOT_OUTPUT_DIR` when explicitly set to `true`. |
|
|
134
134
|
| `KEYSHOT_TIMEOUT_MS` | `600000` | Timeout for one KeyShot headless process. |
|
|
135
135
|
| `KEYSHOT_LICENSE_ARGS` | empty | Optional additional KeyShot launch arguments. |
|
|
@@ -140,11 +140,16 @@ Relative output paths are resolved inside `KEYSHOT_OUTPUT_DIR`. Parent traversal
|
|
|
140
140
|
adjacent-prefix paths, and symbolic-link escapes are rejected by default. Input
|
|
141
141
|
scene, model, material, and environment paths may be located elsewhere.
|
|
142
142
|
|
|
143
|
+
Run `keyshot_status` after installation. It reports the MCP and KeyShot
|
|
144
|
+
versions, resolved executable, output write access, preset validity, headless
|
|
145
|
+
startup result, and actionable suggestions. Additional KeyShot launch arguments
|
|
146
|
+
are never included in the diagnostic output.
|
|
147
|
+
|
|
143
148
|
### Tools
|
|
144
149
|
|
|
145
150
|
| Tool | Purpose |
|
|
146
151
|
| --- | --- |
|
|
147
|
-
| `keyshot_status` |
|
|
152
|
+
| `keyshot_status` | Diagnose local configuration, output access, presets, and KeyShot headless startup. |
|
|
148
153
|
| `keyshot_product_render` | Prepare, save, and render a model or existing scene in one headless process. |
|
|
149
154
|
| `keyshot_inspect_scene` | Inspect objects, cameras, materials, model sets, and references. |
|
|
150
155
|
| `keyshot_list_cameras` | List available camera names. |
|
|
@@ -180,6 +185,19 @@ models default to centered and grounded geometry with a `Product Hero`
|
|
|
180
185
|
isometric camera. Existing scenes keep their current camera, materials, and
|
|
181
186
|
environment unless explicit changes are requested.
|
|
182
187
|
|
|
188
|
+
One-click product rendering defaults to the `standard` quality preset. All
|
|
189
|
+
rendering tools accept:
|
|
190
|
+
|
|
191
|
+
| Preset | Resolution | Samples |
|
|
192
|
+
| --- | ---: | ---: |
|
|
193
|
+
| `preview` | 960 × 540 | 16 |
|
|
194
|
+
| `standard` | 1920 × 1080 | 64 |
|
|
195
|
+
| `final` | 3840 × 2160 | 256 |
|
|
196
|
+
|
|
197
|
+
Explicit `width`, `height`, or `samples` values override the corresponding
|
|
198
|
+
preset value. `maxTimeSeconds` replaces preset samples. Explicit `samples` and
|
|
199
|
+
`maxTimeSeconds` cannot be used together.
|
|
200
|
+
|
|
183
201
|
```json
|
|
184
202
|
{
|
|
185
203
|
"modelPath": "C:/models/speaker.obj",
|
|
@@ -192,16 +210,16 @@ environment unless explicit changes are requested.
|
|
|
192
210
|
"focalLength": 55,
|
|
193
211
|
"brightness": 1.2,
|
|
194
212
|
"rotation": 45,
|
|
195
|
-
"
|
|
196
|
-
"height": 1200,
|
|
197
|
-
"samples": 128
|
|
213
|
+
"qualityPreset": "standard"
|
|
198
214
|
}
|
|
199
215
|
```
|
|
200
216
|
|
|
201
217
|
Use `renderMode: "allCameras"` with `outputDir` to render every named camera.
|
|
202
218
|
Generated names are derived from the source filename when output paths are
|
|
203
|
-
omitted.
|
|
204
|
-
|
|
219
|
+
omitted. If an automatically named result exists, the scene and image receive
|
|
220
|
+
the same `-2`, `-3`, and later suffix. Explicit output paths remain protected
|
|
221
|
+
and return an error when occupied; set `overwrite: true` only when replacement
|
|
222
|
+
is intentional. Material assignments always require an
|
|
205
223
|
explicit object and never overwrite the entire model implicitly.
|
|
206
224
|
|
|
207
225
|
### Product composition examples
|
|
@@ -345,7 +363,7 @@ headless 脚本让兼容 MCP 的 AI Agent 检查、编辑和渲染 KeyShot 场
|
|
|
345
363
|
|
|
346
364
|
### 安装
|
|
347
365
|
|
|
348
|
-
当前版本为 `0.
|
|
366
|
+
当前版本为 `0.9.0`。
|
|
349
367
|
|
|
350
368
|
#### 方式一:使用 npx
|
|
351
369
|
|
|
@@ -356,7 +374,7 @@ headless 脚本让兼容 MCP 的 AI Agent 检查、编辑和渲染 KeyShot 场
|
|
|
356
374
|
"mcpServers": {
|
|
357
375
|
"keyshot": {
|
|
358
376
|
"command": "npx",
|
|
359
|
-
"args": ["-y", "keyshot-mcp@0.
|
|
377
|
+
"args": ["-y", "keyshot-mcp@0.9.0"],
|
|
360
378
|
"env": {
|
|
361
379
|
"KEYSHOT_HEADLESS_EXE": "C:/Program Files/KeyShot Studio/bin/keyshot_headless.exe"
|
|
362
380
|
}
|
|
@@ -368,7 +386,7 @@ headless 脚本让兼容 MCP 的 AI Agent 检查、编辑和渲染 KeyShot 场
|
|
|
368
386
|
#### 方式二:全局安装
|
|
369
387
|
|
|
370
388
|
```bash
|
|
371
|
-
npm install -g keyshot-mcp@0.
|
|
389
|
+
npm install -g keyshot-mcp@0.9.0
|
|
372
390
|
```
|
|
373
391
|
|
|
374
392
|
```json
|
|
@@ -416,11 +434,11 @@ pnpm build
|
|
|
416
434
|
下面的提示词适用于有权限修改 MCP 客户端配置的编程 Agent:
|
|
417
435
|
|
|
418
436
|
```text
|
|
419
|
-
请帮我安装 KeyShot MCP 0.
|
|
437
|
+
请帮我安装 KeyShot MCP 0.9.0,并添加到我的 MCP 客户端。
|
|
420
438
|
|
|
421
439
|
1. 查找本机 KeyShot headless 可执行文件。
|
|
422
440
|
2. 添加名为 keyshot 的 MCP server,运行:
|
|
423
|
-
npx -y keyshot-mcp@0.
|
|
441
|
+
npx -y keyshot-mcp@0.9.0
|
|
424
442
|
3. 将 KEYSHOT_HEADLESS_EXE 设置为可执行文件路径。
|
|
425
443
|
4. 保持 KEYSHOT_ALLOW_EXTERNAL_OUTPUTS 关闭。
|
|
426
444
|
5. 重启或重新加载 MCP 客户端,调用 keyshot_status,然后使用 keyshot_product_render 一键完成产品出图。
|
|
@@ -432,7 +450,7 @@ pnpm build
|
|
|
432
450
|
| 环境变量 | 默认值 | 用途 |
|
|
433
451
|
| --- | --- | --- |
|
|
434
452
|
| `KEYSHOT_HEADLESS_EXE` | Windows 上为 `keyshot_headless.exe` | KeyShot headless 绝对路径,或系统 `PATH` 中的命令。 |
|
|
435
|
-
| `KEYSHOT_OUTPUT_DIR` |
|
|
453
|
+
| `KEYSHOT_OUTPUT_DIR` | `~/Documents/KeyShot MCP Outputs` | 渲染图和已保存场景的根目录。 |
|
|
436
454
|
| `KEYSHOT_ALLOW_EXTERNAL_OUTPUTS` | `false` | 明确设为 `true` 时允许写入输出根目录之外。 |
|
|
437
455
|
| `KEYSHOT_TIMEOUT_MS` | `600000` | 单个 KeyShot headless 进程的超时时间。 |
|
|
438
456
|
| `KEYSHOT_LICENSE_ARGS` | 空 | 可选的 KeyShot 启动参数。 |
|
|
@@ -442,11 +460,15 @@ pnpm build
|
|
|
442
460
|
相对输出路径会自动放入 `KEYSHOT_OUTPUT_DIR`。默认拒绝 `..`、相邻同名前缀目录和
|
|
443
461
|
软链接逃逸。输入场景、模型、材质和环境文件可位于其他目录。
|
|
444
462
|
|
|
463
|
+
安装后调用 `keyshot_status`,可检查 MCP 与 KeyShot 版本、实际可执行文件路径、
|
|
464
|
+
输出目录写入权限、预设文件和 headless 启动状态,并获得可直接执行的修复建议。
|
|
465
|
+
诊断结果不会回显额外的 KeyShot 启动参数。
|
|
466
|
+
|
|
445
467
|
### MCP 工具
|
|
446
468
|
|
|
447
469
|
| 工具 | 用途 |
|
|
448
470
|
| --- | --- |
|
|
449
|
-
| `keyshot_status` |
|
|
471
|
+
| `keyshot_status` | 诊断本地配置、输出权限、预设文件和 KeyShot headless 启动状态。 |
|
|
450
472
|
| `keyshot_product_render` | 在一个 headless 进程中完成模型或场景准备、保存和渲染。 |
|
|
451
473
|
| `keyshot_inspect_scene` | 检查对象、相机、材质、模型集和外部引用。 |
|
|
452
474
|
| `keyshot_list_cameras` | 列出场景中的相机名称。 |
|
|
@@ -480,6 +502,18 @@ pnpm build
|
|
|
480
502
|
贴地并创建名为 `Product Hero` 的等轴测相机;已有场景默认保留当前相机、材质和环境,
|
|
481
503
|
只有明确提供参数时才修改。
|
|
482
504
|
|
|
505
|
+
一键产品出图未指定质量时默认使用 `standard`。全部渲染工具都支持:
|
|
506
|
+
|
|
507
|
+
| 预设 | 分辨率 | 采样 |
|
|
508
|
+
| --- | ---: | ---: |
|
|
509
|
+
| `preview` | 960 × 540 | 16 |
|
|
510
|
+
| `standard` | 1920 × 1080 | 64 |
|
|
511
|
+
| `final` | 3840 × 2160 | 256 |
|
|
512
|
+
|
|
513
|
+
显式填写的 `width`、`height` 或 `samples` 会分别覆盖预设值。
|
|
514
|
+
`maxTimeSeconds` 会替代预设采样;不能同时显式提供 `samples` 和
|
|
515
|
+
`maxTimeSeconds`。
|
|
516
|
+
|
|
483
517
|
```json
|
|
484
518
|
{
|
|
485
519
|
"modelPath": "C:/models/speaker.obj",
|
|
@@ -492,14 +526,13 @@ pnpm build
|
|
|
492
526
|
"focalLength": 55,
|
|
493
527
|
"brightness": 1.2,
|
|
494
528
|
"rotation": 45,
|
|
495
|
-
"
|
|
496
|
-
"height": 1200,
|
|
497
|
-
"samples": 128
|
|
529
|
+
"qualityPreset": "standard"
|
|
498
530
|
}
|
|
499
531
|
```
|
|
500
532
|
|
|
501
533
|
将 `renderMode` 设为 `allCameras` 并提供 `outputDir`,即可渲染场景中的全部命名相机。
|
|
502
|
-
|
|
534
|
+
省略输出路径时会根据源文件名自动生成。自动名称已存在时,场景和图片会统一增加
|
|
535
|
+
`-2`、`-3` 等编号;用户明确填写的路径仍会严格防止覆盖。只有明确设置
|
|
503
536
|
`overwrite: true` 才会替换。材质指定必须包含明确的对象,不会隐式覆盖整个模型。
|
|
504
537
|
|
|
505
538
|
### 产品构图示例
|
package/dist/cli/status.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getConfig } from "../config.js";
|
|
2
|
-
import {
|
|
3
|
-
const result = await
|
|
2
|
+
import { runKeyShotDiagnostics } from "../diagnostics.js";
|
|
3
|
+
const result = await runKeyShotDiagnostics(getConfig());
|
|
4
4
|
console.log(JSON.stringify(result, null, 2));
|
|
5
5
|
process.exit(result.ok ? 0 : 1);
|
package/dist/config.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
+
import os from "node:os";
|
|
2
3
|
import { fileURLToPath } from "node:url";
|
|
3
4
|
const DEFAULT_KEYSHOT_EXE = process.platform === "win32" ? "keyshot_headless.exe" : "keyshot_headless";
|
|
4
5
|
const DEFAULT_TIMEOUT_MS = 600_000;
|
|
5
6
|
export function getConfig() {
|
|
6
7
|
const projectRoot = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
|
|
7
|
-
const keyshotOutputDir = path.resolve(process.env.KEYSHOT_OUTPUT_DIR ??
|
|
8
|
+
const keyshotOutputDir = path.resolve(process.env.KEYSHOT_OUTPUT_DIR ?? defaultOutputDir());
|
|
8
9
|
return {
|
|
9
10
|
projectRoot,
|
|
10
11
|
keyshotHeadlessExe: process.env.KEYSHOT_HEADLESS_EXE ?? DEFAULT_KEYSHOT_EXE,
|
|
@@ -18,6 +19,9 @@ export function getConfig() {
|
|
|
18
19
|
cameraPresetsPath: path.resolve(process.env.KEYSHOT_CAMERA_PRESETS ?? path.join(projectRoot, "presets", "cameras.json")),
|
|
19
20
|
};
|
|
20
21
|
}
|
|
22
|
+
export function defaultOutputDir() {
|
|
23
|
+
return path.join(os.homedir(), "Documents", "KeyShot MCP Outputs");
|
|
24
|
+
}
|
|
21
25
|
function parseBoolean(value) {
|
|
22
26
|
return ["1", "true", "yes", "on"].includes((value ?? "").trim().toLowerCase());
|
|
23
27
|
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { loadCameraPresets } from "./camera-presets.js";
|
|
5
|
+
import { loadMaterialPresets } from "./presets.js";
|
|
6
|
+
import { runKeyShotSerialized } from "./runner.js";
|
|
7
|
+
import { VERSION } from "./version.js";
|
|
8
|
+
export async function runKeyShotDiagnostics(config, runStatus = runKeyShotSerialized) {
|
|
9
|
+
const checks = [];
|
|
10
|
+
const resolvedExecutable = await resolveExecutable(config.keyshotHeadlessExe);
|
|
11
|
+
checks.push({
|
|
12
|
+
id: "keyshot-executable",
|
|
13
|
+
label: "KeyShot headless executable",
|
|
14
|
+
ok: resolvedExecutable !== null,
|
|
15
|
+
severity: "error",
|
|
16
|
+
message: resolvedExecutable
|
|
17
|
+
? `Resolved KeyShot headless executable: ${resolvedExecutable}`
|
|
18
|
+
: `KeyShot headless executable was not found: ${config.keyshotHeadlessExe}`,
|
|
19
|
+
suggestion: resolvedExecutable
|
|
20
|
+
? undefined
|
|
21
|
+
: "Set KEYSHOT_HEADLESS_EXE to the full path of keyshot_headless.exe and restart the MCP client.",
|
|
22
|
+
});
|
|
23
|
+
checks.push(await fileCheck("bridge-script", "KeyShot bridge script", config.bridgeScriptPath, "Reinstall keyshot-mcp so scripts/keyshot_bridge.py is included."));
|
|
24
|
+
checks.push(await outputWriteCheck(config.keyshotOutputDir));
|
|
25
|
+
checks.push(await presetCheck("camera-presets", "Camera presets", () => loadCameraPresets(config), config.cameraPresetsPath));
|
|
26
|
+
checks.push(await presetCheck("material-presets", "Material presets", () => loadMaterialPresets(config), config.materialPresetsPath));
|
|
27
|
+
let keyshotResult;
|
|
28
|
+
if (resolvedExecutable && checks.find((check) => check.id === "bridge-script")?.ok) {
|
|
29
|
+
keyshotResult = await runStatus(config, { operation: "status" });
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
keyshotResult = {
|
|
33
|
+
ok: false,
|
|
34
|
+
data: null,
|
|
35
|
+
outputFiles: [],
|
|
36
|
+
warnings: [],
|
|
37
|
+
keyshotStdoutTail: "",
|
|
38
|
+
error: "KeyShot startup was skipped because a required local file was not available.",
|
|
39
|
+
errorCode: "DIAGNOSTIC_PREREQUISITE_FAILED",
|
|
40
|
+
suggestions: [],
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
checks.push({
|
|
44
|
+
id: "keyshot-startup",
|
|
45
|
+
label: "KeyShot headless startup",
|
|
46
|
+
ok: keyshotResult.ok,
|
|
47
|
+
severity: "error",
|
|
48
|
+
message: keyshotResult.ok
|
|
49
|
+
? "KeyShot headless scripting started successfully."
|
|
50
|
+
: keyshotResult.error ?? "KeyShot headless scripting did not start.",
|
|
51
|
+
suggestion: keyshotResult.ok
|
|
52
|
+
? undefined
|
|
53
|
+
: keyshotResult.suggestions?.[0] ?? "Open KeyShot normally, confirm the license is active, and retry.",
|
|
54
|
+
});
|
|
55
|
+
const ready = checks.every((check) => check.severity !== "error" || check.ok);
|
|
56
|
+
const suggestions = unique([
|
|
57
|
+
...checks.flatMap((check) => !check.ok && check.suggestion ? [check.suggestion] : []),
|
|
58
|
+
...(keyshotResult.suggestions ?? []),
|
|
59
|
+
]);
|
|
60
|
+
const bridgeData = keyshotResult.data && typeof keyshotResult.data === "object"
|
|
61
|
+
? keyshotResult.data
|
|
62
|
+
: {};
|
|
63
|
+
const availableFunctions = Array.isArray(bridgeData.availableFunctions)
|
|
64
|
+
? bridgeData.availableFunctions.filter((name) => typeof name === "string")
|
|
65
|
+
: [];
|
|
66
|
+
const { availableFunctions: _omittedFunctions, ...bridgeSummary } = bridgeData;
|
|
67
|
+
return {
|
|
68
|
+
...keyshotResult,
|
|
69
|
+
ok: ready,
|
|
70
|
+
data: {
|
|
71
|
+
...bridgeSummary,
|
|
72
|
+
availableFunctionCount: availableFunctions.length,
|
|
73
|
+
capabilities: capabilitySummary(availableFunctions),
|
|
74
|
+
serverVersion: VERSION,
|
|
75
|
+
ready,
|
|
76
|
+
config: {
|
|
77
|
+
keyshotHeadlessExe: config.keyshotHeadlessExe,
|
|
78
|
+
resolvedExecutable,
|
|
79
|
+
outputDir: config.keyshotOutputDir,
|
|
80
|
+
allowExternalOutputs: config.keyshotAllowExternalOutputs,
|
|
81
|
+
timeoutMs: config.keyshotTimeoutMs,
|
|
82
|
+
licenseArgsConfigured: config.keyshotLicenseArgs.length > 0,
|
|
83
|
+
bridgeScriptPath: config.bridgeScriptPath,
|
|
84
|
+
cameraPresetsPath: config.cameraPresetsPath,
|
|
85
|
+
materialPresetsPath: config.materialPresetsPath,
|
|
86
|
+
},
|
|
87
|
+
checks,
|
|
88
|
+
suggestions,
|
|
89
|
+
},
|
|
90
|
+
error: ready ? null : keyshotResult.error ?? "KeyShot MCP is not ready. Review the failed diagnostic checks.",
|
|
91
|
+
errorCode: ready ? null : keyshotResult.errorCode ?? "DIAGNOSTIC_FAILED",
|
|
92
|
+
suggestions,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
function capabilitySummary(availableFunctions) {
|
|
96
|
+
const available = new Set(availableFunctions);
|
|
97
|
+
return {
|
|
98
|
+
render: available.has("renderImage"),
|
|
99
|
+
inspectObjects: available.has("getObjects"),
|
|
100
|
+
cameras: available.has("getCameras") && available.has("newCamera"),
|
|
101
|
+
standardViews: available.has("setStandardView"),
|
|
102
|
+
importModel: available.has("importFile"),
|
|
103
|
+
materials: available.has("setObjectMaterial"),
|
|
104
|
+
renderOptions: available.has("getRenderOptions"),
|
|
105
|
+
environments: available.has("getActiveEnvironment"),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
export async function resolveExecutable(command) {
|
|
109
|
+
if (path.isAbsolute(command) || command.includes("/") || command.includes("\\")) {
|
|
110
|
+
const candidate = path.resolve(command);
|
|
111
|
+
return await isFile(candidate) ? candidate : null;
|
|
112
|
+
}
|
|
113
|
+
const pathEntries = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean);
|
|
114
|
+
const extensions = process.platform === "win32" && path.extname(command) === ""
|
|
115
|
+
? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";")
|
|
116
|
+
: [""];
|
|
117
|
+
for (const entry of pathEntries) {
|
|
118
|
+
for (const extension of extensions) {
|
|
119
|
+
const candidate = path.join(entry.replace(/^"|"$/g, ""), `${command}${extension}`);
|
|
120
|
+
if (await isFile(candidate))
|
|
121
|
+
return path.resolve(candidate);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
async function fileCheck(id, label, filePath, suggestion) {
|
|
127
|
+
const ok = await isFile(filePath);
|
|
128
|
+
return { id, label, ok, severity: "error", message: ok ? `Found: ${filePath}` : `Missing: ${filePath}`, suggestion: ok ? undefined : suggestion };
|
|
129
|
+
}
|
|
130
|
+
async function outputWriteCheck(outputDir) {
|
|
131
|
+
const probe = path.join(outputDir, `.keyshot-mcp-write-test-${randomUUID()}`);
|
|
132
|
+
try {
|
|
133
|
+
await fs.mkdir(outputDir, { recursive: true });
|
|
134
|
+
await fs.writeFile(probe, "ok", "utf8");
|
|
135
|
+
await fs.rm(probe, { force: true });
|
|
136
|
+
return {
|
|
137
|
+
id: "output-directory",
|
|
138
|
+
label: "Output directory",
|
|
139
|
+
ok: true,
|
|
140
|
+
severity: "error",
|
|
141
|
+
message: `Output directory is writable: ${outputDir}`,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
await fs.rm(probe, { force: true }).catch(() => undefined);
|
|
146
|
+
return {
|
|
147
|
+
id: "output-directory",
|
|
148
|
+
label: "Output directory",
|
|
149
|
+
ok: false,
|
|
150
|
+
severity: "error",
|
|
151
|
+
message: `Output directory is not writable: ${outputDir} (${errorMessage(error)})`,
|
|
152
|
+
suggestion: "Set KEYSHOT_OUTPUT_DIR to a local folder where the current user can create files.",
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
async function presetCheck(id, label, loader, source) {
|
|
157
|
+
try {
|
|
158
|
+
const presets = await loader();
|
|
159
|
+
return {
|
|
160
|
+
id,
|
|
161
|
+
label,
|
|
162
|
+
ok: true,
|
|
163
|
+
severity: "warning",
|
|
164
|
+
message: `${label} loaded successfully (${presets.length}) from ${source}`,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
return {
|
|
169
|
+
id,
|
|
170
|
+
label,
|
|
171
|
+
ok: false,
|
|
172
|
+
severity: "warning",
|
|
173
|
+
message: errorMessage(error),
|
|
174
|
+
suggestion: `Fix the JSON syntax in ${source}, or remove the custom environment variable to use built-in presets.`,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
async function isFile(value) {
|
|
179
|
+
try {
|
|
180
|
+
return (await fs.stat(value)).isFile();
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function unique(values) {
|
|
187
|
+
return [...new Set(values)];
|
|
188
|
+
}
|
|
189
|
+
function errorMessage(error) {
|
|
190
|
+
return error instanceof Error ? error.message : String(error);
|
|
191
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -9,6 +9,8 @@ import { loadMaterialPresets, findMaterialPreset } from "./presets.js";
|
|
|
9
9
|
import { loadCameraPresets, findCameraPreset } from "./camera-presets.js";
|
|
10
10
|
import { prepareProductRenderRequest } from "./product-render.js";
|
|
11
11
|
import { VERSION } from "./version.js";
|
|
12
|
+
import { applyRenderQuality } from "./quality.js";
|
|
13
|
+
import { runKeyShotDiagnostics } from "./diagnostics.js";
|
|
12
14
|
import { applyMaterialSchema, applyMaterialInputSchema, applyMaterialPresetInputSchema, applyMaterialPresetSchema, applyCameraPresetSchema, batchRenderSchema, batchRenderInputSchema, importModelSchema, listCamerasSchema, listMaterialPresetsSchema, listCameraPresetsSchema, productRenderInputSchema, productRenderSchema, renderQueueSchema, renderQueueInputSchema, renderAllCamerasSchema, renderAllCamerasInputSchema, renderSchema, renderInputSchema, saveSceneSchema, scenePathSchema, setCameraInputSchema, setCameraSchema, setEnvironmentSchema, } from "./schemas.js";
|
|
13
15
|
const config = getConfig();
|
|
14
16
|
const server = new McpServer({
|
|
@@ -64,7 +66,7 @@ server.registerPrompt("keyshot_product_render", {
|
|
|
64
66
|
},
|
|
65
67
|
],
|
|
66
68
|
}));
|
|
67
|
-
server.tool("keyshot_status", "
|
|
69
|
+
server.tool("keyshot_status", "Diagnose the local KeyShot MCP installation and verify headless startup.", {}, async () => toolResponse(await runKeyShotDiagnostics(config)));
|
|
68
70
|
server.tool("keyshot_product_render", "Prepare and render a product from either a model file or an existing KeyShot scene in one headless process.", productRenderInputSchema.shape, async (args) => {
|
|
69
71
|
try {
|
|
70
72
|
const parsed = productRenderSchema.parse(args);
|
|
@@ -79,19 +81,19 @@ server.tool("keyshot_inspect_scene", "Open a KeyShot scene and return available
|
|
|
79
81
|
server.tool("keyshot_list_cameras", "Open a KeyShot scene and return the list of available camera names (useful before batch rendering).", listCamerasSchema.shape, async (args) => toolResponse(await runKeyShotSerialized(config, { operation: "list_cameras", ...args })));
|
|
80
82
|
server.tool("keyshot_render", "Render a KeyShot scene to an image file.", renderSchema.shape, async (args) => {
|
|
81
83
|
const parsed = renderInputSchema.parse(args);
|
|
82
|
-
return toolResponse(await runKeyShotSerialized(config, { operation: "render", ...parsed }));
|
|
84
|
+
return toolResponse(await runKeyShotSerialized(config, { operation: "render", ...applyRenderQuality(parsed) }));
|
|
83
85
|
});
|
|
84
86
|
server.tool("keyshot_render_queue", "Render several jobs sequentially. Stops at the first failure unless continueOnError is set.", renderQueueSchema.shape, async (args) => {
|
|
85
87
|
const parsed = renderQueueInputSchema.parse(args);
|
|
86
|
-
return toolResponse(await runRenderQueue(config, parsed.jobs, { continueOnError: parsed.continueOnError ?? false }));
|
|
88
|
+
return toolResponse(await runRenderQueue(config, parsed.jobs.map((job) => applyRenderQuality(job)), { continueOnError: parsed.continueOnError ?? false }));
|
|
87
89
|
});
|
|
88
90
|
server.tool("keyshot_batch_render", "Render multiple named cameras from one KeyShot scene into an output directory.", batchRenderSchema.shape, async (args) => {
|
|
89
91
|
const parsed = batchRenderInputSchema.parse(args);
|
|
90
|
-
return toolResponse(await runKeyShotSerialized(config, { operation: "batch_render", ...parsed }));
|
|
92
|
+
return toolResponse(await runKeyShotSerialized(config, { operation: "batch_render", ...applyRenderQuality(parsed) }));
|
|
91
93
|
});
|
|
92
94
|
server.tool("keyshot_render_all_cameras", "Discover every camera in one KeyShot scene and render each view into an output directory.", renderAllCamerasSchema.shape, async (args) => {
|
|
93
95
|
const parsed = renderAllCamerasInputSchema.parse(args);
|
|
94
|
-
return toolResponse(await runKeyShotSerialized(config, { operation: "render_all_cameras", ...parsed }));
|
|
96
|
+
return toolResponse(await runKeyShotSerialized(config, { operation: "render_all_cameras", ...applyRenderQuality(parsed) }));
|
|
95
97
|
});
|
|
96
98
|
server.tool("keyshot_import_model", "Import a model into an optional base scene, optionally center and ground it, adjust the camera or environment, and save the resulting scene.", importModelSchema.shape, async (args) => toolResponse(await runKeyShotSerialized(config, {
|
|
97
99
|
operation: "import_model",
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const PRODUCT_OUTPUT_FIELDS = ["outputScenePath", "outputPath", "outputDir"];
|
|
4
|
+
export async function allocateAutomaticProductOutputs(request) {
|
|
5
|
+
const configured = Array.isArray(request._automaticOutputFields) ? request._automaticOutputFields : [];
|
|
6
|
+
const automatic = new Set(configured.filter((field) => PRODUCT_OUTPUT_FIELDS.includes(field)));
|
|
7
|
+
const result = { ...request };
|
|
8
|
+
delete result._automaticOutputFields;
|
|
9
|
+
if (result.overwrite === true || automatic.size === 0)
|
|
10
|
+
return result;
|
|
11
|
+
for (let sequence = 1; sequence < 10_000; sequence += 1) {
|
|
12
|
+
const candidates = new Map();
|
|
13
|
+
for (const field of automatic) {
|
|
14
|
+
const value = result[field];
|
|
15
|
+
if (typeof value === "string")
|
|
16
|
+
candidates.set(field, numberedPath(value, field, sequence));
|
|
17
|
+
}
|
|
18
|
+
const occupied = await Promise.all([...candidates.values()].map(exists));
|
|
19
|
+
if (occupied.some(Boolean))
|
|
20
|
+
continue;
|
|
21
|
+
for (const [field, value] of candidates)
|
|
22
|
+
result[field] = value;
|
|
23
|
+
return result;
|
|
24
|
+
}
|
|
25
|
+
throw new Error("Could not find an available automatic output name after 9999 attempts.");
|
|
26
|
+
}
|
|
27
|
+
function numberedPath(value, field, sequence) {
|
|
28
|
+
if (sequence === 1)
|
|
29
|
+
return value;
|
|
30
|
+
if (field === "outputDir")
|
|
31
|
+
return `${value}-${sequence}`;
|
|
32
|
+
const parsed = path.parse(value);
|
|
33
|
+
return path.join(parsed.dir, `${parsed.name}-${sequence}${parsed.ext}`);
|
|
34
|
+
}
|
|
35
|
+
async function exists(value) {
|
|
36
|
+
try {
|
|
37
|
+
await fs.lstat(value);
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
package/dist/product-render.js
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { findCameraPreset, loadCameraPresets } from "./camera-presets.js";
|
|
3
3
|
import { findMaterialPreset, loadMaterialPresets } from "./presets.js";
|
|
4
|
+
import { applyRenderQuality } from "./quality.js";
|
|
4
5
|
export async function prepareProductRenderRequest(config, input) {
|
|
5
6
|
const sourcePath = input.modelPath ?? input.scenePath;
|
|
6
7
|
const stem = safeStem(sourcePath);
|
|
7
8
|
const format = input.format ?? "png";
|
|
8
9
|
const renderMode = input.renderMode ?? "single";
|
|
9
|
-
const
|
|
10
|
+
const automaticOutputFields = [];
|
|
11
|
+
if (!input.outputScenePath)
|
|
12
|
+
automaticOutputFields.push("outputScenePath");
|
|
13
|
+
if (renderMode === "single" && !input.outputPath)
|
|
14
|
+
automaticOutputFields.push("outputPath");
|
|
15
|
+
if (renderMode === "allCameras" && !input.outputDir)
|
|
16
|
+
automaticOutputFields.push("outputDir");
|
|
17
|
+
const request = applyRenderQuality({
|
|
10
18
|
...input,
|
|
11
19
|
operation: "product_render",
|
|
12
20
|
renderMode,
|
|
@@ -14,7 +22,8 @@ export async function prepareProductRenderRequest(config, input) {
|
|
|
14
22
|
outputScenePath: input.outputScenePath ?? `${stem}-product.bip`,
|
|
15
23
|
overwrite: input.overwrite ?? false,
|
|
16
24
|
continueOnError: input.continueOnError ?? true,
|
|
17
|
-
|
|
25
|
+
_automaticOutputFields: automaticOutputFields,
|
|
26
|
+
}, "standard");
|
|
18
27
|
if (renderMode === "single") {
|
|
19
28
|
request.outputPath = input.outputPath ?? `${stem}-product.${format}`;
|
|
20
29
|
delete request.outputDir;
|
package/dist/quality.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export const QUALITY_PRESETS = {
|
|
2
|
+
preview: { width: 960, height: 540, samples: 16 },
|
|
3
|
+
standard: { width: 1920, height: 1080, samples: 64 },
|
|
4
|
+
final: { width: 3840, height: 2160, samples: 256 },
|
|
5
|
+
};
|
|
6
|
+
export function applyRenderQuality(input, defaultPreset) {
|
|
7
|
+
const selected = input.qualityPreset ?? defaultPreset;
|
|
8
|
+
if (!selected)
|
|
9
|
+
return { ...input };
|
|
10
|
+
const preset = QUALITY_PRESETS[selected];
|
|
11
|
+
const result = {
|
|
12
|
+
...input,
|
|
13
|
+
qualityPreset: selected,
|
|
14
|
+
width: input.width ?? preset.width,
|
|
15
|
+
height: input.height ?? preset.height,
|
|
16
|
+
};
|
|
17
|
+
if (input.maxTimeSeconds !== undefined) {
|
|
18
|
+
delete result.samples;
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
result.samples = input.samples ?? preset.samples;
|
|
22
|
+
}
|
|
23
|
+
return result;
|
|
24
|
+
}
|
package/dist/result.js
CHANGED
|
@@ -10,6 +10,7 @@ export function toolResponse(result) {
|
|
|
10
10
|
};
|
|
11
11
|
}
|
|
12
12
|
export function localFailure(error, extra) {
|
|
13
|
+
const guidance = classifyError(error);
|
|
13
14
|
return {
|
|
14
15
|
ok: false,
|
|
15
16
|
data: extra?.data ?? null,
|
|
@@ -17,5 +18,81 @@ export function localFailure(error, extra) {
|
|
|
17
18
|
warnings: extra?.warnings ?? [],
|
|
18
19
|
keyshotStdoutTail: extra?.keyshotStdoutTail ?? "",
|
|
19
20
|
error,
|
|
21
|
+
errorCode: extra?.errorCode ?? guidance.errorCode,
|
|
22
|
+
suggestions: extra?.suggestions ?? guidance.suggestions,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export function withErrorGuidance(result) {
|
|
26
|
+
if (result.ok || !result.error)
|
|
27
|
+
return result;
|
|
28
|
+
const guidance = classifyError(result.error);
|
|
29
|
+
return {
|
|
30
|
+
...result,
|
|
31
|
+
errorCode: result.errorCode ?? guidance.errorCode,
|
|
32
|
+
suggestions: result.suggestions ?? guidance.suggestions,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function classifyError(error) {
|
|
36
|
+
const normalized = error.toLowerCase();
|
|
37
|
+
if (normalized.includes("headless executable not found") ||
|
|
38
|
+
(normalized.includes("path command") && normalized.includes("not found"))) {
|
|
39
|
+
return {
|
|
40
|
+
errorCode: "KEYSHOT_NOT_FOUND",
|
|
41
|
+
suggestions: ["Set KEYSHOT_HEADLESS_EXE to the full path of keyshot_headless.exe, then restart the MCP client."],
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
if (normalized.includes("license") || normalized.includes("activation")) {
|
|
45
|
+
return {
|
|
46
|
+
errorCode: "LICENSE_UNAVAILABLE",
|
|
47
|
+
suggestions: ["Open KeyShot normally and confirm that its local license is active, then retry."],
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
if (normalized.includes("could not start keyshot")) {
|
|
51
|
+
return {
|
|
52
|
+
errorCode: "KEYSHOT_START_FAILED",
|
|
53
|
+
suggestions: ["Check the KeyShot executable path and start KeyShot normally once before retrying headless mode."],
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
if (normalized.includes("timed out")) {
|
|
57
|
+
return {
|
|
58
|
+
errorCode: "KEYSHOT_TIMEOUT",
|
|
59
|
+
suggestions: ["Try the preview quality preset or increase KEYSHOT_TIMEOUT_MS for a long render."],
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
if (normalized.includes("output already exists") || normalized.includes("overwrite is false")) {
|
|
63
|
+
return {
|
|
64
|
+
errorCode: "OUTPUT_EXISTS",
|
|
65
|
+
suggestions: ["Choose another explicit output name or set overwrite=true only when replacement is intentional."],
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
if (normalized.includes("output path must stay inside")) {
|
|
69
|
+
return {
|
|
70
|
+
errorCode: "OUTPUT_OUTSIDE_ALLOWED_DIRECTORY",
|
|
71
|
+
suggestions: ["Use a path inside KEYSHOT_OUTPUT_DIR, or explicitly enable external outputs if that location is trusted."],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
if (normalized.includes("scene file not found") ||
|
|
75
|
+
normalized.includes("model file not found") ||
|
|
76
|
+
normalized.includes("base scene file not found")) {
|
|
77
|
+
return {
|
|
78
|
+
errorCode: "INPUT_NOT_FOUND",
|
|
79
|
+
suggestions: ["Check that the input file still exists and provide its full local path."],
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
if (normalized.includes("preset not found")) {
|
|
83
|
+
return {
|
|
84
|
+
errorCode: "PRESET_NOT_FOUND",
|
|
85
|
+
suggestions: ["List the available presets first, then retry with one of the returned names."],
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
if (normalized.includes("unsupported")) {
|
|
89
|
+
return {
|
|
90
|
+
errorCode: "UNSUPPORTED_KEYSHOT_API",
|
|
91
|
+
suggestions: ["This KeyShot version does not expose the required headless API; use a supported option or verify a newer KeyShot release."],
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
errorCode: "KEYSHOT_OPERATION_FAILED",
|
|
96
|
+
suggestions: ["Review the error and KeyShot output, then retry with simpler settings or run keyshot_status."],
|
|
20
97
|
};
|
|
21
98
|
}
|
package/dist/runner.js
CHANGED
|
@@ -2,8 +2,9 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import { localFailure } from "./result.js";
|
|
5
|
+
import { localFailure, withErrorGuidance } from "./result.js";
|
|
6
6
|
import { normalizeOutputPaths } from "./output-paths.js";
|
|
7
|
+
import { allocateAutomaticProductOutputs } from "./output-collisions.js";
|
|
7
8
|
let queue = Promise.resolve();
|
|
8
9
|
export function runKeyShotSerialized(config, request) {
|
|
9
10
|
const run = queue.then(() => runKeyShot(config, request));
|
|
@@ -26,6 +27,9 @@ async function runKeyShot(config, request) {
|
|
|
26
27
|
let normalizedRequest;
|
|
27
28
|
try {
|
|
28
29
|
normalizedRequest = await normalizeOutputPaths(config, request);
|
|
30
|
+
if (normalizedRequest.operation === "product_render") {
|
|
31
|
+
normalizedRequest = await allocateAutomaticProductOutputs(normalizedRequest);
|
|
32
|
+
}
|
|
29
33
|
}
|
|
30
34
|
catch (error) {
|
|
31
35
|
return localFailure(errorMessage(error));
|
|
@@ -105,13 +109,13 @@ async function runKeyShot(config, request) {
|
|
|
105
109
|
if (stderrTail)
|
|
106
110
|
parsed.warnings = [...parsed.warnings, `stderr: ${stderrTail}`];
|
|
107
111
|
if (processResult.exitCode !== 0 && parsed.ok) {
|
|
108
|
-
return {
|
|
112
|
+
return withErrorGuidance({
|
|
109
113
|
...parsed,
|
|
110
114
|
ok: false,
|
|
111
115
|
error: `KeyShot exited with code ${processResult.exitCode}`,
|
|
112
|
-
};
|
|
116
|
+
});
|
|
113
117
|
}
|
|
114
|
-
return parsed;
|
|
118
|
+
return withErrorGuidance(parsed);
|
|
115
119
|
}
|
|
116
120
|
finally {
|
|
117
121
|
// Best-effort cleanup so work/tmp does not accumulate args/result files.
|
package/dist/schemas.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
const optionalPath = z.string().min(1).optional();
|
|
3
3
|
const imageFormat = z.enum(["png", "jpg", "jpeg", "tif", "tiff", "exr"]);
|
|
4
|
+
export const qualityPresetSchema = z.enum(["preview", "standard", "final"]);
|
|
4
5
|
export const scenePathSchema = z.object({
|
|
5
6
|
scenePath: z.string().min(1),
|
|
6
7
|
});
|
|
@@ -16,6 +17,7 @@ export const renderSchema = z.object({
|
|
|
16
17
|
maxTimeSeconds: z.number().positive().optional(),
|
|
17
18
|
camera: z.string().min(1).optional(),
|
|
18
19
|
format: z.enum(["png", "jpg", "jpeg", "tif", "tiff", "exr"]).optional(),
|
|
20
|
+
qualityPreset: qualityPresetSchema.optional(),
|
|
19
21
|
});
|
|
20
22
|
export const renderInputSchema = renderSchema.refine((value) => !(value.samples !== undefined && value.maxTimeSeconds !== undefined), {
|
|
21
23
|
message: "Choose either samples or maxTimeSeconds, not both.",
|
|
@@ -30,6 +32,7 @@ export const batchRenderSchema = z.object({
|
|
|
30
32
|
samples: z.number().int().positive().optional(),
|
|
31
33
|
maxTimeSeconds: z.number().positive().optional(),
|
|
32
34
|
format: z.enum(["png", "jpg", "jpeg", "tif", "tiff", "exr"]).optional(),
|
|
35
|
+
qualityPreset: qualityPresetSchema.optional(),
|
|
33
36
|
overwrite: z.boolean().optional(),
|
|
34
37
|
});
|
|
35
38
|
export const batchRenderInputSchema = batchRenderSchema.refine((value) => !(value.samples !== undefined && value.maxTimeSeconds !== undefined), {
|
|
@@ -44,6 +47,7 @@ export const renderAllCamerasSchema = z.object({
|
|
|
44
47
|
samples: z.number().int().positive().optional(),
|
|
45
48
|
maxTimeSeconds: z.number().positive().optional(),
|
|
46
49
|
format: imageFormat.optional(),
|
|
50
|
+
qualityPreset: qualityPresetSchema.optional(),
|
|
47
51
|
overwrite: z.boolean().optional(),
|
|
48
52
|
continueOnError: z.boolean().default(true),
|
|
49
53
|
});
|
|
@@ -123,6 +127,7 @@ export const productRenderInputSchema = z.object({
|
|
|
123
127
|
samples: z.number().int().positive().optional(),
|
|
124
128
|
maxTimeSeconds: z.number().positive().optional(),
|
|
125
129
|
format: imageFormat.optional(),
|
|
130
|
+
qualityPreset: qualityPresetSchema.optional(),
|
|
126
131
|
overwrite: z.boolean().default(false),
|
|
127
132
|
continueOnError: z.boolean().default(true),
|
|
128
133
|
});
|
|
@@ -218,6 +223,7 @@ export const renderJobSchema = z.object({
|
|
|
218
223
|
samples: z.number().int().positive().optional(),
|
|
219
224
|
maxTimeSeconds: z.number().positive().optional(),
|
|
220
225
|
format: imageFormat.optional(),
|
|
226
|
+
qualityPreset: qualityPresetSchema.optional(),
|
|
221
227
|
});
|
|
222
228
|
export const renderJobInputSchema = renderJobSchema.refine((value) => !(value.samples !== undefined && value.maxTimeSeconds !== undefined), {
|
|
223
229
|
message: "Choose either samples or maxTimeSeconds, not both.",
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.
|
|
1
|
+
export const VERSION = "0.9.0";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keyshot-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"mcpName": "io.github.truman-t3/keyshot-mcp",
|
|
5
5
|
"description": "Local MCP server for KeyShot product visualization, scene editing, camera control, and rendering.",
|
|
6
6
|
"type": "module",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"check": "tsc -p tsconfig.json --noEmit",
|
|
23
23
|
"test": "vitest run",
|
|
24
24
|
"status": "node ./dist/cli/status.js",
|
|
25
|
-
"smoke:keyshot": "
|
|
25
|
+
"smoke:keyshot": "tsc -p tsconfig.json && node ./scripts/keyshot-smoke.mjs"
|
|
26
26
|
},
|
|
27
27
|
"keywords": [
|
|
28
28
|
"mcp",
|
|
@@ -4,6 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { findCameraPreset, loadCameraPresets } from "../dist/camera-presets.js";
|
|
6
6
|
import { getConfig } from "../dist/config.js";
|
|
7
|
+
import { runKeyShotDiagnostics } from "../dist/diagnostics.js";
|
|
7
8
|
import { prepareProductRenderRequest } from "../dist/product-render.js";
|
|
8
9
|
import { runKeyShotSerialized } from "../dist/runner.js";
|
|
9
10
|
|
|
@@ -37,8 +38,14 @@ async function applyStandardPreset(label, scenePath, presetName, outputScenePath
|
|
|
37
38
|
});
|
|
38
39
|
}
|
|
39
40
|
|
|
40
|
-
|
|
41
|
-
const
|
|
41
|
+
process.stdout.write("1/14 KeyShot diagnostics... ");
|
|
42
|
+
const status = await runKeyShotDiagnostics(config);
|
|
43
|
+
if (!status.ok || !status.data?.ready) {
|
|
44
|
+
process.stdout.write("failed\n");
|
|
45
|
+
throw new Error(`KeyShot diagnostics: ${status.error}\n${(status.suggestions ?? []).join("\n")}`);
|
|
46
|
+
}
|
|
47
|
+
process.stdout.write("ok\n");
|
|
48
|
+
const imported = await run("2/14 Import, center, and ground generated OBJ", {
|
|
42
49
|
operation: "import_model",
|
|
43
50
|
modelPath,
|
|
44
51
|
outputScenePath: "demo/keyshot-mcp-demo-import.bip",
|
|
@@ -50,7 +57,7 @@ const imported = await run("2/13 Import, center, and ground generated OBJ", {
|
|
|
50
57
|
const importedScene = imported.outputFiles[0];
|
|
51
58
|
if (!importedScene) throw new Error("Import did not return a saved scene path.");
|
|
52
59
|
|
|
53
|
-
const inspected = await run("3/
|
|
60
|
+
const inspected = await run("3/14 Inspect imported scene", {
|
|
54
61
|
operation: "inspect_scene",
|
|
55
62
|
scenePath: importedScene,
|
|
56
63
|
});
|
|
@@ -60,7 +67,7 @@ if (!Array.isArray(objects) || objects.length === 0) {
|
|
|
60
67
|
}
|
|
61
68
|
|
|
62
69
|
const firstCameraResult = await applyStandardPreset(
|
|
63
|
-
"4/
|
|
70
|
+
"4/14 Apply Front camera preset",
|
|
64
71
|
importedScene,
|
|
65
72
|
"Front",
|
|
66
73
|
"demo/keyshot-mcp-demo-preset-front.bip",
|
|
@@ -68,7 +75,7 @@ const firstCameraResult = await applyStandardPreset(
|
|
|
68
75
|
const firstCameraScene = firstCameraResult.outputFiles[0];
|
|
69
76
|
if (!firstCameraScene) throw new Error("Front preset did not return a saved scene path.");
|
|
70
77
|
|
|
71
|
-
const focalLengthResult = await run("5/
|
|
78
|
+
const focalLengthResult = await run("5/14 Set Front camera focal length", {
|
|
72
79
|
operation: "set_camera",
|
|
73
80
|
scenePath: firstCameraScene,
|
|
74
81
|
cameraName: "Front",
|
|
@@ -78,7 +85,7 @@ const focalLengthResult = await run("5/13 Set Front camera focal length", {
|
|
|
78
85
|
const focalLengthScene = focalLengthResult.outputFiles[0];
|
|
79
86
|
if (!focalLengthScene) throw new Error("Focal-length update did not return a saved scene path.");
|
|
80
87
|
|
|
81
|
-
const environmentResult = await run("6/
|
|
88
|
+
const environmentResult = await run("6/14 Rotate the active environment", {
|
|
82
89
|
operation: "set_environment",
|
|
83
90
|
scenePath: focalLengthScene,
|
|
84
91
|
rotation: 45,
|
|
@@ -88,7 +95,7 @@ const environmentScene = environmentResult.outputFiles[0];
|
|
|
88
95
|
if (!environmentScene) throw new Error("Environment update did not return a saved scene path.");
|
|
89
96
|
|
|
90
97
|
const secondCameraResult = await applyStandardPreset(
|
|
91
|
-
"7/
|
|
98
|
+
"7/14 Apply Isometric camera preset",
|
|
92
99
|
environmentScene,
|
|
93
100
|
"Isometric",
|
|
94
101
|
"demo/keyshot-mcp-demo-presets.bip",
|
|
@@ -96,7 +103,7 @@ const secondCameraResult = await applyStandardPreset(
|
|
|
96
103
|
const cameraScene = secondCameraResult.outputFiles[0];
|
|
97
104
|
if (!cameraScene) throw new Error("Isometric preset did not return a saved scene path.");
|
|
98
105
|
|
|
99
|
-
const fieldOfViewResult = await run("8/
|
|
106
|
+
const fieldOfViewResult = await run("8/14 Set Isometric camera field of view and distance", {
|
|
100
107
|
operation: "set_camera",
|
|
101
108
|
scenePath: cameraScene,
|
|
102
109
|
cameraName: "Isometric",
|
|
@@ -107,7 +114,7 @@ const fieldOfViewResult = await run("8/13 Set Isometric camera field of view and
|
|
|
107
114
|
const productCameraScene = fieldOfViewResult.outputFiles[0];
|
|
108
115
|
if (!productCameraScene) throw new Error("Field-of-view update did not return a saved scene path.");
|
|
109
116
|
|
|
110
|
-
const rendered = await run("9/
|
|
117
|
+
const rendered = await run("9/14 Discover and render every camera", {
|
|
111
118
|
operation: "render_all_cameras",
|
|
112
119
|
scenePath: productCameraScene,
|
|
113
120
|
outputDir: "demo/all-cameras",
|
|
@@ -140,26 +147,35 @@ const minimumSizes = await Promise.all(namedResults.map(async (entry) => (await
|
|
|
140
147
|
if (minimumSizes.some((size) => size < 10000)) {
|
|
141
148
|
throw new Error("A demo camera render is unexpectedly small and may be blank.");
|
|
142
149
|
}
|
|
143
|
-
process.stdout.write("10/
|
|
144
|
-
process.stdout.write("11/
|
|
150
|
+
process.stdout.write("10/14 Verify two different camera images... ok\n");
|
|
151
|
+
process.stdout.write("11/14 Verify composition, lens, and environment operations... ok\n");
|
|
145
152
|
|
|
146
153
|
const oneClickModelRequest = await prepareProductRenderRequest(config, {
|
|
147
154
|
modelPath,
|
|
148
|
-
outputScenePath: "demo/one-click-cube.bip",
|
|
149
|
-
outputPath: "demo/one-click-cube.png",
|
|
150
155
|
renderMode: "single",
|
|
151
|
-
|
|
152
|
-
height: 480,
|
|
153
|
-
maxTimeSeconds: 8,
|
|
156
|
+
qualityPreset: "standard",
|
|
154
157
|
format: "png",
|
|
155
|
-
overwrite:
|
|
158
|
+
overwrite: false,
|
|
156
159
|
continueOnError: true,
|
|
157
160
|
});
|
|
158
|
-
const oneClickModel = await run("12/
|
|
161
|
+
const oneClickModel = await run("12/14 Standard-quality one-click product render", oneClickModelRequest);
|
|
159
162
|
if (!oneClickModel.data?.savedScene || oneClickModel.data?.renders?.length !== 1) {
|
|
160
163
|
throw new Error("One-click model workflow did not return a scene and one render.");
|
|
161
164
|
}
|
|
162
165
|
|
|
166
|
+
const repeatedRequest = await prepareProductRenderRequest(config, {
|
|
167
|
+
modelPath,
|
|
168
|
+
renderMode: "single",
|
|
169
|
+
qualityPreset: "preview",
|
|
170
|
+
format: "png",
|
|
171
|
+
overwrite: false,
|
|
172
|
+
continueOnError: true,
|
|
173
|
+
});
|
|
174
|
+
const repeatedModel = await run("13/14 Repeated preview render with automatic numbering", repeatedRequest);
|
|
175
|
+
if (!repeatedModel.data?.savedScene || repeatedModel.data?.savedScene === oneClickModel.data.savedScene) {
|
|
176
|
+
throw new Error("Repeated one-click render did not allocate a new numbered scene path.");
|
|
177
|
+
}
|
|
178
|
+
|
|
163
179
|
const oneClickSceneRequest = await prepareProductRenderRequest(config, {
|
|
164
180
|
scenePath: productCameraScene,
|
|
165
181
|
outputScenePath: "demo/one-click-existing-scene.bip",
|
|
@@ -172,7 +188,7 @@ const oneClickSceneRequest = await prepareProductRenderRequest(config, {
|
|
|
172
188
|
overwrite: true,
|
|
173
189
|
continueOnError: true,
|
|
174
190
|
});
|
|
175
|
-
const oneClickScene = await run("
|
|
191
|
+
const oneClickScene = await run("14/14 One-click existing-scene all-camera render", oneClickSceneRequest);
|
|
176
192
|
if (!oneClickScene.data?.savedScene || oneClickScene.data?.renders?.length < 2) {
|
|
177
193
|
throw new Error("One-click scene workflow did not preserve the scene and render all cameras.");
|
|
178
194
|
}
|
|
@@ -183,7 +199,9 @@ console.log(JSON.stringify({
|
|
|
183
199
|
scenePath: productCameraScene,
|
|
184
200
|
cameraCount: renderData.total,
|
|
185
201
|
renderedImages: rendered.outputFiles,
|
|
186
|
-
oneClickOutputs: [...oneClickModel.outputFiles, ...oneClickScene.outputFiles],
|
|
202
|
+
oneClickOutputs: [...oneClickModel.outputFiles, ...repeatedModel.outputFiles, ...oneClickScene.outputFiles],
|
|
203
|
+
diagnosticChecks: status.data?.checks ?? [],
|
|
204
|
+
verifiedQualities: ["standard", "preview"],
|
|
187
205
|
verifiedPresets: namedResults.map((entry) => entry.camera),
|
|
188
206
|
verifiedControls: {
|
|
189
207
|
importOptions: ["centerGeometry", "snapToGround", "adjustCameraLookAt", "adjustEnvironment"],
|
package/server.json
CHANGED
|
@@ -9,12 +9,12 @@
|
|
|
9
9
|
"url": "https://github.com/truman-t3/keyshot-mcp",
|
|
10
10
|
"source": "github"
|
|
11
11
|
},
|
|
12
|
-
"version": "0.
|
|
12
|
+
"version": "0.9.0",
|
|
13
13
|
"packages": [
|
|
14
14
|
{
|
|
15
15
|
"registryType": "npm",
|
|
16
16
|
"identifier": "keyshot-mcp",
|
|
17
|
-
"version": "0.
|
|
17
|
+
"version": "0.9.0",
|
|
18
18
|
"transport": {
|
|
19
19
|
"type": "stdio"
|
|
20
20
|
},
|