keyshot-mcp 0.4.1 → 0.5.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 +123 -22
- package/assets/demo/keyshot-mcp-demo.png +0 -0
- package/dist/config.js +4 -0
- package/dist/index.js +7 -2
- package/dist/output-paths.js +55 -0
- package/dist/runner.js +61 -12
- package/dist/schemas.js +15 -0
- package/dist/version.js +1 -0
- package/examples/demo/README.md +10 -0
- package/examples/demo/keyshot-mcp-cube.obj +23 -0
- package/package.json +8 -2
- package/scripts/keyshot-smoke.mjs +107 -0
- package/scripts/keyshot_bridge.py +141 -22
- package/server.json +31 -0
package/README.md
CHANGED
|
@@ -28,6 +28,7 @@ This project does not include KeyShot, does not bypass licensing, and does not s
|
|
|
28
28
|
- Inspect a `.bip` or supported KeyShot scene file.
|
|
29
29
|
- Render a scene to an image.
|
|
30
30
|
- Batch render multiple named cameras from one scene.
|
|
31
|
+
- Automatically discover and render every camera in one scene.
|
|
31
32
|
- Import a model into a scene.
|
|
32
33
|
- Apply a material to an object.
|
|
33
34
|
- Create or update a camera.
|
|
@@ -67,7 +68,7 @@ This project does not include KeyShot, does not bypass licensing, and does not s
|
|
|
67
68
|
npm install -g keyshot-mcp
|
|
68
69
|
```
|
|
69
70
|
|
|
70
|
-
The current release line is `0.
|
|
71
|
+
The current release line is `0.5.0`. The published package is the easiest option
|
|
71
72
|
when you only want to use the MCP server. You still need KeyShot Studio and a
|
|
72
73
|
local KeyShot license.
|
|
73
74
|
|
|
@@ -78,21 +79,41 @@ npm install
|
|
|
78
79
|
npm run build
|
|
79
80
|
```
|
|
80
81
|
|
|
82
|
+
### Three MCP configuration options
|
|
83
|
+
|
|
84
|
+
Use exactly one of these approaches:
|
|
85
|
+
|
|
86
|
+
**Global npm installation** (`npm install -g keyshot-mcp`):
|
|
87
|
+
|
|
88
|
+
```json
|
|
89
|
+
{"mcpServers":{"keyshot":{"command":"keyshot-mcp","env":{"KEYSHOT_HEADLESS_EXE":"/absolute/path/to/keyshot_headless"}}}}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**No installation, run with npx**:
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
{"mcpServers":{"keyshot":{"command":"npx","args":["-y","keyshot-mcp@0.5.0"],"env":{"KEYSHOT_HEADLESS_EXE":"/absolute/path/to/keyshot_headless"}}}}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
**Run from a cloned source directory** (after `npm install && npm run build`):
|
|
99
|
+
|
|
100
|
+
```json
|
|
101
|
+
{"mcpServers":{"keyshot":{"command":"node","args":["/absolute/path/to/keyshot-mcp/dist/index.js"],"env":{"KEYSHOT_HEADLESS_EXE":"/absolute/path/to/keyshot_headless"}}}}
|
|
102
|
+
```
|
|
103
|
+
|
|
81
104
|
## Copy-Paste Setup Prompt for an Agent
|
|
82
105
|
|
|
83
106
|
If you use Codex or another coding agent, you can copy this prompt and let the agent install the MCP server for you:
|
|
84
107
|
|
|
85
108
|
```text
|
|
86
|
-
Install
|
|
109
|
+
Install KeyShot MCP 0.5.0 from npm and configure it in my MCP client.
|
|
87
110
|
|
|
88
111
|
Please:
|
|
89
|
-
1.
|
|
90
|
-
2.
|
|
91
|
-
3.
|
|
92
|
-
4.
|
|
93
|
-
5.
|
|
94
|
-
6. Test the setup by running the keyshot_status tool.
|
|
95
|
-
7. Tell me the exact config that was added and whether the status check passed.
|
|
112
|
+
1. Find my KeyShot headless executable path.
|
|
113
|
+
2. Add a keyshot MCP server using npx -y keyshot-mcp@0.5.0.
|
|
114
|
+
3. Set KEYSHOT_HEADLESS_EXE to the detected keyshot_headless path.
|
|
115
|
+
4. Test the setup by running the keyshot_status tool.
|
|
116
|
+
5. Tell me the exact config that was added and whether the status check passed.
|
|
96
117
|
|
|
97
118
|
Do not store license keys or account passwords. Use my existing local KeyShot license configuration.
|
|
98
119
|
```
|
|
@@ -182,12 +203,22 @@ List the cameras in this KeyShot scene, create or update a camera named "Hero",
|
|
|
182
203
|
save the scene to a new file, and render one preview from that camera.
|
|
183
204
|
```
|
|
184
205
|
|
|
206
|
+
```text
|
|
207
|
+
Find every camera in this KeyShot scene and render all views to the all-cameras
|
|
208
|
+
output folder. Continue if one camera fails and report the result for each view.
|
|
209
|
+
```
|
|
210
|
+
|
|
185
211
|
## Environment Variables
|
|
186
212
|
|
|
187
213
|
- `KEYSHOT_HEADLESS_EXE`: path to `keyshot_headless` or `keyshot_headless.exe`.
|
|
188
214
|
- `KEYSHOT_OUTPUT_DIR`: default output folder for renders.
|
|
189
215
|
- `KEYSHOT_LICENSE_ARGS`: optional KeyShot headless license arguments. Empty by default.
|
|
190
216
|
- `KEYSHOT_TIMEOUT_MS`: operation timeout in milliseconds. Default: `600000`.
|
|
217
|
+
- `KEYSHOT_ALLOW_EXTERNAL_OUTPUTS`: allow output paths outside `KEYSHOT_OUTPUT_DIR`. Default: `false`.
|
|
218
|
+
|
|
219
|
+
By default every output file must stay inside `KEYSHOT_OUTPUT_DIR`. Relative paths
|
|
220
|
+
are placed there automatically. Set the compatibility switch to `true` only when
|
|
221
|
+
an external output location is intentional; input scenes and models are unrestricted.
|
|
191
222
|
|
|
192
223
|
`samples` and `maxTimeSeconds` select different KeyShot render modes. Provide one
|
|
193
224
|
or the other, not both.
|
|
@@ -199,6 +230,7 @@ or the other, not both.
|
|
|
199
230
|
- `keyshot_list_cameras`: list available camera names in a scene (handy before batch rendering)
|
|
200
231
|
- `keyshot_render`: render a single image
|
|
201
232
|
- `keyshot_batch_render`
|
|
233
|
+
- `keyshot_render_all_cameras`: discover and render every camera in a scene; continues after individual failures by default
|
|
202
234
|
- `keyshot_render_queue`: run several render jobs sequentially (stops at first failure unless `continueOnError`)
|
|
203
235
|
- `keyshot_import_model`
|
|
204
236
|
- `keyshot_apply_material`
|
|
@@ -208,6 +240,15 @@ or the other, not both.
|
|
|
208
240
|
- `keyshot_set_environment`
|
|
209
241
|
- `keyshot_save_scene`
|
|
210
242
|
|
|
243
|
+
### Render every camera automatically
|
|
244
|
+
|
|
245
|
+
`keyshot_render_all_cameras` discovers camera names from the open scene and renders
|
|
246
|
+
them in one KeyShot headless session. `continueOnError` defaults to `true`, so a
|
|
247
|
+
failed camera is reported without blocking the remaining views. Safe duplicate
|
|
248
|
+
filenames receive `-2`, `-3`, and later suffixes. Existing files are preserved
|
|
249
|
+
unless `overwrite` is set to `true`. KeyShot's internal, non-activatable
|
|
250
|
+
`last_active` placeholder is reported and skipped.
|
|
251
|
+
|
|
211
252
|
### Material preset library
|
|
212
253
|
|
|
213
254
|
`keyshot_apply_material_preset` reads a small JSON registry so you can reuse named
|
|
@@ -244,15 +285,26 @@ KeyShot's Python `lux` API changes across versions. This server keeps the MCP in
|
|
|
244
285
|
|
|
245
286
|
## Roadmap
|
|
246
287
|
|
|
247
|
-
- [
|
|
288
|
+
- [x] Auto-discover and batch render all cameras in a scene
|
|
248
289
|
- [x] Material preset library
|
|
249
290
|
- [ ] Camera preset templates
|
|
250
291
|
- [x] Sequential render queue
|
|
251
|
-
- [
|
|
292
|
+
- [x] Safer output directory restrictions
|
|
252
293
|
- [ ] More tested KeyShot versions
|
|
253
294
|
- [ ] macOS compatibility verification
|
|
254
295
|
- [x] Claude Desktop / Cursor / Codex config examples
|
|
255
296
|
|
|
297
|
+
## Real KeyShot demo
|
|
298
|
+
|
|
299
|
+

|
|
300
|
+
|
|
301
|
+
The demo uses only the generated geometry in `examples/demo`. To reproduce the
|
|
302
|
+
status, import, inspect, two-camera creation, save, discovery, and PNG-render workflow locally:
|
|
303
|
+
|
|
304
|
+
```bash
|
|
305
|
+
npm run smoke:keyshot
|
|
306
|
+
```
|
|
307
|
+
|
|
256
308
|
## License
|
|
257
309
|
|
|
258
310
|
MIT
|
|
@@ -285,6 +337,7 @@ MIT
|
|
|
285
337
|
- 检查 `.bip` 或 KeyShot 支持的场景文件。
|
|
286
338
|
- 把场景渲染成图片。
|
|
287
339
|
- 从同一个场景批量渲染多个指定相机。
|
|
340
|
+
- 自动发现并渲染场景中的全部相机。
|
|
288
341
|
- 把模型导入场景。
|
|
289
342
|
- 给对象替换材质。
|
|
290
343
|
- 创建或更新相机。
|
|
@@ -324,7 +377,7 @@ MIT
|
|
|
324
377
|
npm install -g keyshot-mcp
|
|
325
378
|
```
|
|
326
379
|
|
|
327
|
-
当前发布版本为 `0.
|
|
380
|
+
当前发布版本为 `0.5.0`。如果你只是想使用 MCP 服务,直接安装 npm 包最简单。
|
|
328
381
|
电脑仍需要安装 KeyShot Studio,并且已经配置好本地 KeyShot 授权。
|
|
329
382
|
|
|
330
383
|
### 从源码安装
|
|
@@ -334,21 +387,41 @@ npm install
|
|
|
334
387
|
npm run build
|
|
335
388
|
```
|
|
336
389
|
|
|
390
|
+
### 三种独立配置方式
|
|
391
|
+
|
|
392
|
+
下面三种方式选择一种即可:
|
|
393
|
+
|
|
394
|
+
**全局 npm 安装**(先运行 `npm install -g keyshot-mcp`):
|
|
395
|
+
|
|
396
|
+
```json
|
|
397
|
+
{"mcpServers":{"keyshot":{"command":"keyshot-mcp","env":{"KEYSHOT_HEADLESS_EXE":"C:/KeyShot/bin/keyshot_headless.exe"}}}}
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
**免安装,直接使用 npx**:
|
|
401
|
+
|
|
402
|
+
```json
|
|
403
|
+
{"mcpServers":{"keyshot":{"command":"npx","args":["-y","keyshot-mcp@0.5.0"],"env":{"KEYSHOT_HEADLESS_EXE":"C:/KeyShot/bin/keyshot_headless.exe"}}}}
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
**使用本地源码**(先运行 `npm install && npm run build`):
|
|
407
|
+
|
|
408
|
+
```json
|
|
409
|
+
{"mcpServers":{"keyshot":{"command":"node","args":["C:/path/to/keyshot-mcp/dist/index.js"],"env":{"KEYSHOT_HEADLESS_EXE":"C:/KeyShot/bin/keyshot_headless.exe"}}}}
|
|
410
|
+
```
|
|
411
|
+
|
|
337
412
|
## 复制给 Agent 的安装提示词
|
|
338
413
|
|
|
339
414
|
如果你使用 Codex 或其他编程 Agent,可以复制下面这段话,让 Agent 帮你安装和配置 MCP:
|
|
340
415
|
|
|
341
416
|
```text
|
|
342
|
-
请帮我安装 KeyShot MCP
|
|
417
|
+
请帮我安装 KeyShot MCP 0.5.0,并添加到我的 MCP 客户端。
|
|
343
418
|
|
|
344
419
|
请你:
|
|
345
|
-
1.
|
|
346
|
-
2.
|
|
347
|
-
3.
|
|
348
|
-
4.
|
|
349
|
-
5.
|
|
350
|
-
6. 用 keyshot_status 工具测试是否配置成功。
|
|
351
|
-
7. 最后告诉我实际添加的配置,以及状态检查是否通过。
|
|
420
|
+
1. 查找我电脑上的 KeyShot headless 可执行文件路径。
|
|
421
|
+
2. 使用 npx -y keyshot-mcp@0.5.0 添加 keyshot MCP server。
|
|
422
|
+
3. 把 KEYSHOT_HEADLESS_EXE 设置为检测到的 keyshot_headless 路径。
|
|
423
|
+
4. 用 keyshot_status 工具测试是否配置成功。
|
|
424
|
+
5. 最后告诉我实际添加的配置,以及状态检查是否通过。
|
|
352
425
|
|
|
353
426
|
不要保存许可证密钥、账号密码或授权信息。直接使用我电脑上已有的 KeyShot 本地授权配置。
|
|
354
427
|
```
|
|
@@ -438,6 +511,11 @@ examples/codex.example.json
|
|
|
438
511
|
把场景保存为新文件,并用这个相机渲染一张预览图。
|
|
439
512
|
```
|
|
440
513
|
|
|
514
|
+
```text
|
|
515
|
+
自动找出这个 KeyShot 场景里的全部相机,把所有视角渲染到 all-cameras 文件夹。
|
|
516
|
+
某个相机失败时继续处理其他相机,最后告诉我每个视角的结果。
|
|
517
|
+
```
|
|
518
|
+
|
|
441
519
|
## 环境变量
|
|
442
520
|
|
|
443
521
|
- `KEYSHOT_HEADLESS_EXE`:`keyshot_headless` 或 `keyshot_headless.exe` 的路径。
|
|
@@ -445,6 +523,10 @@ examples/codex.example.json
|
|
|
445
523
|
- `KEYSHOT_LICENSE_ARGS`:可选的 KeyShot 无界面许可证参数,默认留空。
|
|
446
524
|
- `KEYSHOT_TIMEOUT_MS`:单次操作超时时间,单位毫秒,默认 `600000`。
|
|
447
525
|
- `KEYSHOT_MATERIAL_PRESETS`:材质预设库 JSON 文件路径,默认 `presets/materials.json`。
|
|
526
|
+
- `KEYSHOT_ALLOW_EXTERNAL_OUTPUTS`:是否允许写到默认输出目录之外,默认 `false`。
|
|
527
|
+
|
|
528
|
+
默认情况下,所有输出图片和场景都必须写入 `KEYSHOT_OUTPUT_DIR`,相对路径会自动放入该目录。
|
|
529
|
+
只有确实需要写到外部目录时才设置为 `true`;输入场景和模型路径不受此限制。
|
|
448
530
|
|
|
449
531
|
`samples` 和 `maxTimeSeconds` 是两种不同的 KeyShot 渲染模式,请二选一,不要同时传入。
|
|
450
532
|
|
|
@@ -455,6 +537,7 @@ examples/codex.example.json
|
|
|
455
537
|
- `keyshot_list_cameras`:列出场景中所有相机名称(批量渲染前很有用)。
|
|
456
538
|
- `keyshot_render`:渲染单张图片。
|
|
457
539
|
- `keyshot_batch_render`:批量渲染多个相机视角。
|
|
540
|
+
- `keyshot_render_all_cameras`:自动发现并渲染场景中的全部相机,默认单个视角失败后继续。
|
|
458
541
|
- `keyshot_render_queue`:顺序执行多个渲染任务(默认遇错即停,设置 `continueOnError` 可继续)。
|
|
459
542
|
- `keyshot_import_model`:导入模型。
|
|
460
543
|
- `keyshot_apply_material`:替换材质。
|
|
@@ -464,6 +547,13 @@ examples/codex.example.json
|
|
|
464
547
|
- `keyshot_set_environment`:设置环境。
|
|
465
548
|
- `keyshot_save_scene`:保存场景。
|
|
466
549
|
|
|
550
|
+
### 自动渲染全部相机
|
|
551
|
+
|
|
552
|
+
`keyshot_render_all_cameras` 会在同一次 KeyShot 无界面运行中自动读取场景相机并逐个渲染。
|
|
553
|
+
`continueOnError` 默认是 `true`,因此单个相机失败不会阻止其他视角;同名安全文件会自动添加
|
|
554
|
+
`-2`、`-3` 等编号。除非设置 `overwrite: true`,否则不会覆盖已有图片。KeyShot 内部不能激活的
|
|
555
|
+
`last_active` 占位项会被列出并跳过。
|
|
556
|
+
|
|
467
557
|
### 材质预设库
|
|
468
558
|
|
|
469
559
|
`keyshot_apply_material_preset` 会读取一个小的 JSON 注册表,让你用"好记的名字"复用材质,
|
|
@@ -499,15 +589,26 @@ KeyShot 的 Python `lux` API 会随版本变化。这个 MCP 会尽量保持对
|
|
|
499
589
|
|
|
500
590
|
## 路线图
|
|
501
591
|
|
|
502
|
-
- [
|
|
592
|
+
- [x] 自动发现并批量渲染场景中的所有相机
|
|
503
593
|
- [x] 材质预设库
|
|
504
594
|
- [ ] 相机预设模板
|
|
505
595
|
- [x] 顺序渲染队列
|
|
506
|
-
- [
|
|
596
|
+
- [x] 更安全的输出目录限制
|
|
507
597
|
- [ ] 测试更多 KeyShot 版本
|
|
508
598
|
- [ ] 验证 macOS 兼容性
|
|
509
599
|
- [x] 补充 Claude Desktop / Cursor / Codex 配置示例
|
|
510
600
|
|
|
601
|
+
## 真实 KeyShot Demo
|
|
602
|
+
|
|
603
|
+

|
|
604
|
+
|
|
605
|
+
Demo 只使用 `examples/demo` 中项目自己生成的几何体,不包含客户模型或第三方素材。
|
|
606
|
+
设置好 `KEYSHOT_HEADLESS_EXE` 后可复现状态检查、导入、检查场景、创建两个相机、自动发现、保存和真实 PNG 渲染:
|
|
607
|
+
|
|
608
|
+
```bash
|
|
609
|
+
npm run smoke:keyshot
|
|
610
|
+
```
|
|
611
|
+
|
|
511
612
|
## 测试
|
|
512
613
|
|
|
513
614
|
项目带了两套测试,不依赖真实的 KeyShot 也能跑:
|
|
Binary file
|
package/dist/config.js
CHANGED
|
@@ -9,6 +9,7 @@ export function getConfig() {
|
|
|
9
9
|
projectRoot,
|
|
10
10
|
keyshotHeadlessExe: process.env.KEYSHOT_HEADLESS_EXE ?? DEFAULT_KEYSHOT_EXE,
|
|
11
11
|
keyshotOutputDir,
|
|
12
|
+
keyshotAllowExternalOutputs: parseBoolean(process.env.KEYSHOT_ALLOW_EXTERNAL_OUTPUTS),
|
|
12
13
|
keyshotLicenseArgs: splitWindowsArgs(process.env.KEYSHOT_LICENSE_ARGS ?? ""),
|
|
13
14
|
keyshotTimeoutMs: parsePositiveInt(process.env.KEYSHOT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS),
|
|
14
15
|
tmpDir: path.join(projectRoot, "work", "tmp"),
|
|
@@ -16,6 +17,9 @@ export function getConfig() {
|
|
|
16
17
|
materialPresetsPath: path.resolve(process.env.KEYSHOT_MATERIAL_PRESETS ?? path.join(projectRoot, "presets", "materials.json")),
|
|
17
18
|
};
|
|
18
19
|
}
|
|
20
|
+
function parseBoolean(value) {
|
|
21
|
+
return ["1", "true", "yes", "on"].includes((value ?? "").trim().toLowerCase());
|
|
22
|
+
}
|
|
19
23
|
function parsePositiveInt(value, fallback) {
|
|
20
24
|
if (!value)
|
|
21
25
|
return fallback;
|
package/dist/index.js
CHANGED
|
@@ -6,11 +6,12 @@ import { toolResponse, localFailure } from "./result.js";
|
|
|
6
6
|
import { runKeyShotSerialized } from "./runner.js";
|
|
7
7
|
import { runRenderQueue } from "./queue.js";
|
|
8
8
|
import { loadMaterialPresets, findMaterialPreset } from "./presets.js";
|
|
9
|
-
import {
|
|
9
|
+
import { VERSION } from "./version.js";
|
|
10
|
+
import { applyMaterialSchema, applyMaterialInputSchema, applyMaterialPresetInputSchema, applyMaterialPresetSchema, batchRenderSchema, batchRenderInputSchema, importModelSchema, listCamerasSchema, listMaterialPresetsSchema, renderQueueSchema, renderQueueInputSchema, renderAllCamerasSchema, renderAllCamerasInputSchema, renderSchema, renderInputSchema, saveSceneSchema, scenePathSchema, setCameraSchema, setEnvironmentSchema, } from "./schemas.js";
|
|
10
11
|
const config = getConfig();
|
|
11
12
|
const server = new McpServer({
|
|
12
13
|
name: "keyshot-mcp",
|
|
13
|
-
version:
|
|
14
|
+
version: VERSION,
|
|
14
15
|
});
|
|
15
16
|
function errorMessage(error) {
|
|
16
17
|
return error instanceof Error ? error.message : String(error);
|
|
@@ -71,6 +72,10 @@ server.tool("keyshot_batch_render", "Render multiple named cameras from one KeyS
|
|
|
71
72
|
const parsed = batchRenderInputSchema.parse(args);
|
|
72
73
|
return toolResponse(await runKeyShotSerialized(config, { operation: "batch_render", ...parsed }));
|
|
73
74
|
});
|
|
75
|
+
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) => {
|
|
76
|
+
const parsed = renderAllCamerasInputSchema.parse(args);
|
|
77
|
+
return toolResponse(await runKeyShotSerialized(config, { operation: "render_all_cameras", ...parsed }));
|
|
78
|
+
});
|
|
74
79
|
server.tool("keyshot_import_model", "Import a model into an optional base scene and save the resulting scene.", importModelSchema.shape, async (args) => toolResponse(await runKeyShotSerialized(config, {
|
|
75
80
|
operation: "import_model",
|
|
76
81
|
...args,
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const OUTPUT_FIELDS = ["outputPath", "outputDir", "outputScenePath"];
|
|
4
|
+
export async function normalizeOutputPaths(config, request) {
|
|
5
|
+
await fs.mkdir(config.keyshotOutputDir, { recursive: true });
|
|
6
|
+
const normalized = { ...request };
|
|
7
|
+
for (const field of OUTPUT_FIELDS) {
|
|
8
|
+
const value = request[field];
|
|
9
|
+
if (typeof value !== "string" || value.length === 0)
|
|
10
|
+
continue;
|
|
11
|
+
normalized[field] = await normalizeOutputPath(config, value);
|
|
12
|
+
}
|
|
13
|
+
return normalized;
|
|
14
|
+
}
|
|
15
|
+
export async function normalizeOutputPath(config, value) {
|
|
16
|
+
const candidate = path.resolve(config.keyshotOutputDir, value);
|
|
17
|
+
if (config.keyshotAllowExternalOutputs)
|
|
18
|
+
return candidate;
|
|
19
|
+
const outputRoot = await realpathWithMissingTail(config.keyshotOutputDir);
|
|
20
|
+
const resolvedCandidate = await realpathWithMissingTail(candidate);
|
|
21
|
+
if (!isWithin(outputRoot, resolvedCandidate)) {
|
|
22
|
+
throw new Error(`Output path must stay inside KEYSHOT_OUTPUT_DIR (${config.keyshotOutputDir}): ${value}. ` +
|
|
23
|
+
"Set KEYSHOT_ALLOW_EXTERNAL_OUTPUTS=true only if external writes are intentional.");
|
|
24
|
+
}
|
|
25
|
+
return candidate;
|
|
26
|
+
}
|
|
27
|
+
async function realpathWithMissingTail(inputPath) {
|
|
28
|
+
let current = path.resolve(inputPath);
|
|
29
|
+
const missing = [];
|
|
30
|
+
while (!(await exists(current))) {
|
|
31
|
+
const parent = path.dirname(current);
|
|
32
|
+
if (parent === current)
|
|
33
|
+
break;
|
|
34
|
+
missing.unshift(path.basename(current));
|
|
35
|
+
current = parent;
|
|
36
|
+
}
|
|
37
|
+
const realBase = await fs.realpath(current);
|
|
38
|
+
return path.resolve(realBase, ...missing);
|
|
39
|
+
}
|
|
40
|
+
function isWithin(root, candidate) {
|
|
41
|
+
const relative = path.relative(normalizeCase(root), normalizeCase(candidate));
|
|
42
|
+
return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
|
|
43
|
+
}
|
|
44
|
+
function normalizeCase(value) {
|
|
45
|
+
return process.platform === "win32" ? value.toLowerCase() : value;
|
|
46
|
+
}
|
|
47
|
+
async function exists(value) {
|
|
48
|
+
try {
|
|
49
|
+
await fs.lstat(value);
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
package/dist/runner.js
CHANGED
|
@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
|
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { localFailure } from "./result.js";
|
|
6
|
+
import { normalizeOutputPaths } from "./output-paths.js";
|
|
6
7
|
let queue = Promise.resolve();
|
|
7
8
|
export function runKeyShotSerialized(config, request) {
|
|
8
9
|
const run = queue.then(() => runKeyShot(config, request));
|
|
@@ -10,8 +11,7 @@ export function runKeyShotSerialized(config, request) {
|
|
|
10
11
|
return run;
|
|
11
12
|
}
|
|
12
13
|
async function runKeyShot(config, request) {
|
|
13
|
-
|
|
14
|
-
if (!exeExists) {
|
|
14
|
+
if (isPathLike(config.keyshotHeadlessExe) && !(await exists(config.keyshotHeadlessExe))) {
|
|
15
15
|
return localFailure(`KeyShot headless executable not found: ${config.keyshotHeadlessExe}`);
|
|
16
16
|
}
|
|
17
17
|
const bridgeExists = await exists(config.bridgeScriptPath);
|
|
@@ -23,11 +23,18 @@ async function runKeyShot(config, request) {
|
|
|
23
23
|
}
|
|
24
24
|
await fs.mkdir(config.tmpDir, { recursive: true });
|
|
25
25
|
await fs.mkdir(config.keyshotOutputDir, { recursive: true });
|
|
26
|
+
let normalizedRequest;
|
|
27
|
+
try {
|
|
28
|
+
normalizedRequest = await normalizeOutputPaths(config, request);
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
return localFailure(errorMessage(error));
|
|
32
|
+
}
|
|
26
33
|
const id = `${Date.now()}-${randomUUID()}`;
|
|
27
34
|
const argsPath = path.join(config.tmpDir, `${id}.args.json`);
|
|
28
35
|
const resultPath = path.join(config.tmpDir, `${id}.result.json`);
|
|
29
36
|
const payload = {
|
|
30
|
-
...
|
|
37
|
+
...normalizedRequest,
|
|
31
38
|
defaults: {
|
|
32
39
|
outputDir: config.keyshotOutputDir,
|
|
33
40
|
},
|
|
@@ -36,7 +43,7 @@ async function runKeyShot(config, request) {
|
|
|
36
43
|
const args = [
|
|
37
44
|
...config.keyshotLicenseArgs,
|
|
38
45
|
"-progress",
|
|
39
|
-
...(
|
|
46
|
+
...(normalizedRequest.scenePath ? [normalizedRequest.scenePath] : []),
|
|
40
47
|
"-script",
|
|
41
48
|
config.bridgeScriptPath,
|
|
42
49
|
argsPath,
|
|
@@ -58,6 +65,12 @@ async function runKeyShot(config, request) {
|
|
|
58
65
|
});
|
|
59
66
|
}
|
|
60
67
|
}
|
|
68
|
+
if (processResult.spawnError) {
|
|
69
|
+
return localFailure(`Could not start KeyShot headless: ${processResult.spawnError}`, {
|
|
70
|
+
keyshotStdoutTail: stdoutTail,
|
|
71
|
+
warnings: stderrTail ? [`stderr: ${stderrTail}`] : [],
|
|
72
|
+
});
|
|
73
|
+
}
|
|
61
74
|
if (processResult.timedOut) {
|
|
62
75
|
return localFailure(`KeyShot timed out after ${config.keyshotTimeoutMs}ms`, {
|
|
63
76
|
data: parsed?.data ?? null,
|
|
@@ -96,9 +109,11 @@ async function cleanupTmp(paths) {
|
|
|
96
109
|
}
|
|
97
110
|
}));
|
|
98
111
|
}
|
|
99
|
-
|
|
112
|
+
export const MAX_CAPTURE_CHARS = 64 * 1024;
|
|
113
|
+
export function spawnWithTimeout(command, args, timeoutMs) {
|
|
100
114
|
return new Promise((resolve) => {
|
|
101
115
|
const child = spawn(command, args, {
|
|
116
|
+
detached: process.platform !== "win32",
|
|
102
117
|
windowsHide: true,
|
|
103
118
|
stdio: ["ignore", "pipe", "pipe"],
|
|
104
119
|
});
|
|
@@ -106,30 +121,64 @@ function spawnWithTimeout(command, args, timeoutMs) {
|
|
|
106
121
|
let stderr = "";
|
|
107
122
|
let settled = false;
|
|
108
123
|
let timedOut = false;
|
|
124
|
+
let spawnError = null;
|
|
109
125
|
const timer = setTimeout(() => {
|
|
110
126
|
timedOut = true;
|
|
111
|
-
child.
|
|
127
|
+
terminateProcessTree(child.pid, "SIGTERM");
|
|
112
128
|
setTimeout(() => {
|
|
113
|
-
if (!settled)
|
|
114
|
-
child.
|
|
129
|
+
if (!settled && process.platform !== "win32")
|
|
130
|
+
terminateProcessTree(child.pid, "SIGKILL");
|
|
115
131
|
}, 2500).unref();
|
|
116
132
|
}, timeoutMs);
|
|
117
133
|
child.stdout?.on("data", (chunk) => {
|
|
118
|
-
stdout
|
|
134
|
+
stdout = appendBounded(stdout, String(chunk));
|
|
119
135
|
});
|
|
120
136
|
child.stderr?.on("data", (chunk) => {
|
|
121
|
-
stderr
|
|
137
|
+
stderr = appendBounded(stderr, String(chunk));
|
|
122
138
|
});
|
|
123
139
|
child.on("error", (error) => {
|
|
124
|
-
|
|
140
|
+
spawnError = errorMessage(error);
|
|
141
|
+
stderr = appendBounded(stderr, spawnError);
|
|
125
142
|
});
|
|
126
143
|
child.on("close", (exitCode) => {
|
|
127
144
|
settled = true;
|
|
128
145
|
clearTimeout(timer);
|
|
129
|
-
resolve({ exitCode, stdout, stderr, timedOut });
|
|
146
|
+
resolve({ exitCode, stdout, stderr, timedOut, spawnError });
|
|
130
147
|
});
|
|
131
148
|
});
|
|
132
149
|
}
|
|
150
|
+
function terminateProcessTree(pid, signal) {
|
|
151
|
+
if (!pid)
|
|
152
|
+
return;
|
|
153
|
+
if (process.platform === "win32") {
|
|
154
|
+
const killer = spawn("taskkill", ["/pid", String(pid), "/T", "/F"], {
|
|
155
|
+
windowsHide: true,
|
|
156
|
+
stdio: "ignore",
|
|
157
|
+
});
|
|
158
|
+
killer.on("error", () => undefined);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
process.kill(-pid, signal);
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
try {
|
|
166
|
+
process.kill(pid, signal);
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
// The process may already have exited.
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
function appendBounded(current, addition) {
|
|
174
|
+
const combined = current + addition;
|
|
175
|
+
return combined.length <= MAX_CAPTURE_CHARS
|
|
176
|
+
? combined
|
|
177
|
+
: combined.slice(combined.length - MAX_CAPTURE_CHARS);
|
|
178
|
+
}
|
|
179
|
+
function isPathLike(command) {
|
|
180
|
+
return path.isAbsolute(command) || command.includes("/") || command.includes("\\");
|
|
181
|
+
}
|
|
133
182
|
async function exists(filePath) {
|
|
134
183
|
try {
|
|
135
184
|
await fs.access(filePath);
|
package/dist/schemas.js
CHANGED
|
@@ -36,6 +36,21 @@ export const batchRenderInputSchema = batchRenderSchema.refine((value) => !(valu
|
|
|
36
36
|
message: "Choose either samples or maxTimeSeconds, not both.",
|
|
37
37
|
path: ["maxTimeSeconds"],
|
|
38
38
|
});
|
|
39
|
+
export const renderAllCamerasSchema = z.object({
|
|
40
|
+
scenePath: z.string().min(1),
|
|
41
|
+
outputDir: z.string().min(1),
|
|
42
|
+
width: z.number().int().positive().optional(),
|
|
43
|
+
height: z.number().int().positive().optional(),
|
|
44
|
+
samples: z.number().int().positive().optional(),
|
|
45
|
+
maxTimeSeconds: z.number().positive().optional(),
|
|
46
|
+
format: imageFormat.optional(),
|
|
47
|
+
overwrite: z.boolean().optional(),
|
|
48
|
+
continueOnError: z.boolean().default(true),
|
|
49
|
+
});
|
|
50
|
+
export const renderAllCamerasInputSchema = renderAllCamerasSchema.refine((value) => !(value.samples !== undefined && value.maxTimeSeconds !== undefined), {
|
|
51
|
+
message: "Choose either samples or maxTimeSeconds, not both.",
|
|
52
|
+
path: ["maxTimeSeconds"],
|
|
53
|
+
});
|
|
39
54
|
export const importModelSchema = z.object({
|
|
40
55
|
modelPath: z.string().min(1),
|
|
41
56
|
baseScenePath: optionalPath,
|
package/dist/version.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const VERSION = "0.5.0";
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# KeyShot smoke-test demo
|
|
2
|
+
|
|
3
|
+
`keyshot-mcp-cube.obj` is original, generated geometry included under this
|
|
4
|
+
repository's MIT license. It contains no customer model or third-party asset.
|
|
5
|
+
|
|
6
|
+
Run `npm run smoke:keyshot` after setting `KEYSHOT_HEADLESS_EXE`. The command
|
|
7
|
+
checks KeyShot, imports the cube, saves and inspects the scene, creates two MCP
|
|
8
|
+
cameras, automatically discovers them, and renders both views under
|
|
9
|
+
`outputs/demo/all-cameras`. Generated `.bip` and PNG files remain under
|
|
10
|
+
`outputs/` and are ignored by Git.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# KeyShot MCP generated demo cube
|
|
2
|
+
# Original geometry released under the repository MIT license.
|
|
3
|
+
o KeyShot_MCP_Cube
|
|
4
|
+
v -1.0 -1.0 -1.0
|
|
5
|
+
v 1.0 -1.0 -1.0
|
|
6
|
+
v 1.0 1.0 -1.0
|
|
7
|
+
v -1.0 1.0 -1.0
|
|
8
|
+
v -1.0 -1.0 1.0
|
|
9
|
+
v 1.0 -1.0 1.0
|
|
10
|
+
v 1.0 1.0 1.0
|
|
11
|
+
v -1.0 1.0 1.0
|
|
12
|
+
vn 0.0 0.0 -1.0
|
|
13
|
+
vn 0.0 0.0 1.0
|
|
14
|
+
vn 0.0 -1.0 0.0
|
|
15
|
+
vn 0.0 1.0 0.0
|
|
16
|
+
vn -1.0 0.0 0.0
|
|
17
|
+
vn 1.0 0.0 0.0
|
|
18
|
+
f 1//1 4//1 3//1 2//1
|
|
19
|
+
f 5//2 6//2 7//2 8//2
|
|
20
|
+
f 1//3 2//3 6//3 5//3
|
|
21
|
+
f 4//4 8//4 7//4 3//4
|
|
22
|
+
f 1//5 5//5 8//5 4//5
|
|
23
|
+
f 2//6 3//6 7//6 6//6
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keyshot-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"mcpName": "io.github.truman-t3/keyshot-mcp",
|
|
4
5
|
"description": "Local and portable MCP server for KeyShot headless scripting.",
|
|
5
6
|
"type": "module",
|
|
6
7
|
"homepage": "https://github.com/truman-t3/keyshot-mcp#readme",
|
|
@@ -20,7 +21,8 @@
|
|
|
20
21
|
"start": "node ./dist/index.js",
|
|
21
22
|
"check": "tsc -p tsconfig.json --noEmit",
|
|
22
23
|
"test": "vitest run",
|
|
23
|
-
"status": "node ./dist/cli/status.js"
|
|
24
|
+
"status": "node ./dist/cli/status.js",
|
|
25
|
+
"smoke:keyshot": "npm run build && node ./scripts/keyshot-smoke.mjs"
|
|
24
26
|
},
|
|
25
27
|
"keywords": [
|
|
26
28
|
"mcp",
|
|
@@ -33,8 +35,11 @@
|
|
|
33
35
|
"files": [
|
|
34
36
|
"dist",
|
|
35
37
|
"scripts/keyshot_bridge.py",
|
|
38
|
+
"scripts/keyshot-smoke.mjs",
|
|
36
39
|
"examples",
|
|
40
|
+
"assets/demo",
|
|
37
41
|
"presets",
|
|
42
|
+
"server.json",
|
|
38
43
|
"README.md",
|
|
39
44
|
"CONTRIBUTING.md",
|
|
40
45
|
"SECURITY.md",
|
|
@@ -44,6 +49,7 @@
|
|
|
44
49
|
"engines": {
|
|
45
50
|
"node": ">=20"
|
|
46
51
|
},
|
|
52
|
+
"packageManager": "pnpm@10.17.1",
|
|
47
53
|
"dependencies": {
|
|
48
54
|
"@modelcontextprotocol/sdk": "^1.18.1",
|
|
49
55
|
"zod": "^3.25.76"
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { getConfig } from "../dist/config.js";
|
|
6
|
+
import { runKeyShotSerialized } from "../dist/runner.js";
|
|
7
|
+
|
|
8
|
+
const repoRoot = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
|
|
9
|
+
const config = getConfig();
|
|
10
|
+
const modelPath = path.join(repoRoot, "examples", "demo", "keyshot-mcp-cube.obj");
|
|
11
|
+
|
|
12
|
+
async function run(label, request) {
|
|
13
|
+
process.stdout.write(`${label}... `);
|
|
14
|
+
const result = await runKeyShotSerialized(config, request);
|
|
15
|
+
if (!result.ok) {
|
|
16
|
+
process.stdout.write("failed\n");
|
|
17
|
+
throw new Error(`${label}: ${result.error}\n${result.warnings.join("\n")}`);
|
|
18
|
+
}
|
|
19
|
+
process.stdout.write("ok\n");
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const status = await run("1/7 KeyShot status", { operation: "status" });
|
|
24
|
+
const imported = await run("2/7 Import and save generated OBJ", {
|
|
25
|
+
operation: "import_model",
|
|
26
|
+
modelPath,
|
|
27
|
+
outputScenePath: "demo/keyshot-mcp-demo-import.bip",
|
|
28
|
+
});
|
|
29
|
+
const importedScene = imported.outputFiles[0];
|
|
30
|
+
if (!importedScene) throw new Error("Import did not return a saved scene path.");
|
|
31
|
+
|
|
32
|
+
const inspected = await run("3/7 Inspect imported scene", {
|
|
33
|
+
operation: "inspect_scene",
|
|
34
|
+
scenePath: importedScene,
|
|
35
|
+
});
|
|
36
|
+
const objects = inspected.data?.objects;
|
|
37
|
+
if (!Array.isArray(objects) || objects.length === 0) {
|
|
38
|
+
throw new Error("Imported scene did not contain any inspectable objects.");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const firstCameraResult = await run("4/7 Create first camera and save scene", {
|
|
42
|
+
operation: "set_camera",
|
|
43
|
+
scenePath: importedScene,
|
|
44
|
+
cameraName: "MCP Front",
|
|
45
|
+
position: [4.5, 3.5, 4.5],
|
|
46
|
+
lookAt: [0, 0, 0],
|
|
47
|
+
up: [0, 1, 0],
|
|
48
|
+
outputScenePath: "demo/keyshot-mcp-demo-camera-front.bip",
|
|
49
|
+
});
|
|
50
|
+
const firstCameraScene = firstCameraResult.outputFiles[0];
|
|
51
|
+
if (!firstCameraScene) throw new Error("First camera operation did not return a saved scene path.");
|
|
52
|
+
|
|
53
|
+
const secondCameraResult = await run("5/7 Create second camera and save scene", {
|
|
54
|
+
operation: "set_camera",
|
|
55
|
+
scenePath: firstCameraScene,
|
|
56
|
+
cameraName: "MCP Angle",
|
|
57
|
+
position: [6.0, 2.5, 3.0],
|
|
58
|
+
lookAt: [0, 0, 0],
|
|
59
|
+
up: [0, 1, 0],
|
|
60
|
+
outputScenePath: "demo/keyshot-mcp-demo-camera-all.bip",
|
|
61
|
+
});
|
|
62
|
+
const cameraScene = secondCameraResult.outputFiles[0];
|
|
63
|
+
if (!cameraScene) throw new Error("Second camera operation did not return a saved scene path.");
|
|
64
|
+
|
|
65
|
+
const rendered = await run("6/7 Discover and render every camera", {
|
|
66
|
+
operation: "render_all_cameras",
|
|
67
|
+
scenePath: cameraScene,
|
|
68
|
+
outputDir: "demo/all-cameras",
|
|
69
|
+
width: 640,
|
|
70
|
+
height: 480,
|
|
71
|
+
maxTimeSeconds: 8,
|
|
72
|
+
format: "png",
|
|
73
|
+
overwrite: true,
|
|
74
|
+
continueOnError: true,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const renderData = rendered.data;
|
|
78
|
+
if (!renderData || renderData.failed !== 0 || renderData.succeeded < 2) {
|
|
79
|
+
throw new Error("All-camera render did not produce at least two successful camera views.");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const namedResults = renderData.results.filter(
|
|
83
|
+
(entry) => entry.camera === "MCP Front" || entry.camera === "MCP Angle",
|
|
84
|
+
);
|
|
85
|
+
if (namedResults.length !== 2 || namedResults.some((entry) => !entry.ok)) {
|
|
86
|
+
throw new Error("The two MCP demo cameras were not both rendered successfully.");
|
|
87
|
+
}
|
|
88
|
+
const hashes = await Promise.all(namedResults.map(async (entry) =>
|
|
89
|
+
crypto.createHash("sha256").update(await fs.readFile(entry.outputPath)).digest("hex")
|
|
90
|
+
));
|
|
91
|
+
if (hashes[0] === hashes[1]) {
|
|
92
|
+
throw new Error("The two demo camera renders are identical; expected different viewpoints.");
|
|
93
|
+
}
|
|
94
|
+
const minimumSizes = await Promise.all(namedResults.map(async (entry) => (await fs.stat(entry.outputPath)).size));
|
|
95
|
+
if (minimumSizes.some((size) => size < 10000)) {
|
|
96
|
+
throw new Error("A demo camera render is unexpectedly small and may be blank.");
|
|
97
|
+
}
|
|
98
|
+
process.stdout.write("7/7 Verify two different camera images... ok\n");
|
|
99
|
+
|
|
100
|
+
console.log(JSON.stringify({
|
|
101
|
+
keyshotVersion: status.data?.version ?? null,
|
|
102
|
+
objectCount: objects.length,
|
|
103
|
+
scenePath: cameraScene,
|
|
104
|
+
cameraCount: renderData.total,
|
|
105
|
+
renderedImages: rendered.outputFiles,
|
|
106
|
+
verifiedCameras: namedResults.map((entry) => entry.camera),
|
|
107
|
+
}, null, 2));
|
|
@@ -37,6 +37,8 @@ def main():
|
|
|
37
37
|
data = render(payload, output_files, warnings)
|
|
38
38
|
elif operation == "batch_render":
|
|
39
39
|
data = batch_render(payload, output_files, warnings)
|
|
40
|
+
elif operation == "render_all_cameras":
|
|
41
|
+
data = render_all_cameras(payload, output_files, warnings)
|
|
40
42
|
elif operation == "import_model":
|
|
41
43
|
data = import_model(payload, output_files, warnings)
|
|
42
44
|
elif operation == "apply_material":
|
|
@@ -50,7 +52,10 @@ def main():
|
|
|
50
52
|
else:
|
|
51
53
|
raise RuntimeError("Unsupported operation: %s" % operation)
|
|
52
54
|
|
|
53
|
-
|
|
55
|
+
operation_error = None
|
|
56
|
+
if operation == "render_all_cameras" and data.get("failed", 0) > 0:
|
|
57
|
+
operation_error = "%s of %s camera render(s) failed." % (data["failed"], data["total"])
|
|
58
|
+
write_result(result_path, operation_error is None, data, output_files, warnings, operation_error)
|
|
54
59
|
except Exception as exc:
|
|
55
60
|
warnings.append(traceback.format_exc())
|
|
56
61
|
write_result(result_path, False, None, output_files, warnings, str(exc))
|
|
@@ -99,6 +104,11 @@ def inspect_scene():
|
|
|
99
104
|
|
|
100
105
|
|
|
101
106
|
def list_cameras():
|
|
107
|
+
names = camera_names()
|
|
108
|
+
return {"cameras": names, "count": len(names)}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def camera_names():
|
|
102
112
|
raw = safe_list_call("getCameras")
|
|
103
113
|
names = []
|
|
104
114
|
for camera in raw:
|
|
@@ -113,8 +123,8 @@ def list_cameras():
|
|
|
113
123
|
)
|
|
114
124
|
if name is None:
|
|
115
125
|
name = repr(camera)
|
|
116
|
-
names.append(name)
|
|
117
|
-
return
|
|
126
|
+
names.append(str(name))
|
|
127
|
+
return names
|
|
118
128
|
|
|
119
129
|
|
|
120
130
|
def render(payload, output_files, warnings):
|
|
@@ -214,6 +224,100 @@ def batch_render(payload, output_files, warnings):
|
|
|
214
224
|
}
|
|
215
225
|
|
|
216
226
|
|
|
227
|
+
def render_all_cameras(payload, output_files, warnings):
|
|
228
|
+
scene_path = payload.get("scenePath")
|
|
229
|
+
output_dir = payload.get("outputDir")
|
|
230
|
+
discovered_cameras = camera_names()
|
|
231
|
+
excluded_cameras = [name for name in discovered_cameras if name.lower() == "last_active"]
|
|
232
|
+
cameras = [name for name in discovered_cameras if name.lower() != "last_active"]
|
|
233
|
+
width = payload.get("width") or 1920
|
|
234
|
+
height = payload.get("height") or 1080
|
|
235
|
+
image_format = payload.get("format") or "png"
|
|
236
|
+
overwrite = bool(payload.get("overwrite", False))
|
|
237
|
+
continue_on_error = payload.get("continueOnError", True) is not False
|
|
238
|
+
|
|
239
|
+
if not scene_path:
|
|
240
|
+
raise RuntimeError("scenePath is required")
|
|
241
|
+
if not output_dir:
|
|
242
|
+
raise RuntimeError("outputDir is required")
|
|
243
|
+
if not cameras:
|
|
244
|
+
raise RuntimeError("No cameras were found in the scene.")
|
|
245
|
+
|
|
246
|
+
if excluded_cameras:
|
|
247
|
+
warnings.append("Skipped KeyShot internal camera placeholder(s): %s" % ", ".join(excluded_cameras))
|
|
248
|
+
|
|
249
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
250
|
+
results = []
|
|
251
|
+
used_stems = set()
|
|
252
|
+
stopped = False
|
|
253
|
+
|
|
254
|
+
for index, camera in enumerate(cameras):
|
|
255
|
+
output_path = unique_camera_output_path(output_dir, camera, image_format, used_stems)
|
|
256
|
+
if stopped:
|
|
257
|
+
results.append(camera_render_result(index, camera, output_path, False, None, True))
|
|
258
|
+
continue
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
if os.path.exists(output_path) and not overwrite:
|
|
262
|
+
raise RuntimeError("Output already exists and overwrite is false: %s" % output_path)
|
|
263
|
+
|
|
264
|
+
render_payload = dict(payload)
|
|
265
|
+
render_payload["camera"] = camera
|
|
266
|
+
render_payload["outputPath"] = output_path
|
|
267
|
+
render_payload["width"] = width
|
|
268
|
+
render_payload["height"] = height
|
|
269
|
+
render_payload["format"] = image_format
|
|
270
|
+
rendered = render(render_payload, output_files, warnings)
|
|
271
|
+
results.append(camera_render_result(index, camera, rendered.get("rendered"), True, None, False))
|
|
272
|
+
except Exception as exc:
|
|
273
|
+
error = str(exc)
|
|
274
|
+
warnings.append("Camera %s (%s) failed: %s" % (index, camera, error))
|
|
275
|
+
results.append(camera_render_result(index, camera, output_path, False, error, False))
|
|
276
|
+
if not continue_on_error:
|
|
277
|
+
stopped = True
|
|
278
|
+
|
|
279
|
+
succeeded = len([item for item in results if item["ok"]])
|
|
280
|
+
failed = len([item for item in results if not item["ok"] and not item["skipped"]])
|
|
281
|
+
skipped = len([item for item in results if item["skipped"]])
|
|
282
|
+
return {
|
|
283
|
+
"scenePath": scene_path,
|
|
284
|
+
"outputDir": output_dir,
|
|
285
|
+
"cameras": cameras,
|
|
286
|
+
"excludedCameras": excluded_cameras,
|
|
287
|
+
"total": len(cameras),
|
|
288
|
+
"succeeded": succeeded,
|
|
289
|
+
"failed": failed,
|
|
290
|
+
"skipped": skipped,
|
|
291
|
+
"continueOnError": continue_on_error,
|
|
292
|
+
"results": results,
|
|
293
|
+
"width": width,
|
|
294
|
+
"height": height,
|
|
295
|
+
"format": image_format,
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def camera_render_result(index, camera, output_path, ok, error, skipped):
|
|
300
|
+
return {
|
|
301
|
+
"index": index,
|
|
302
|
+
"camera": camera,
|
|
303
|
+
"outputPath": output_path,
|
|
304
|
+
"ok": ok,
|
|
305
|
+
"error": error,
|
|
306
|
+
"skipped": skipped,
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def unique_camera_output_path(output_dir, camera, image_format, used_stems):
|
|
311
|
+
base = safe_filename(camera)
|
|
312
|
+
stem = base
|
|
313
|
+
suffix = 2
|
|
314
|
+
while stem.lower() in used_stems:
|
|
315
|
+
stem = "%s-%s" % (base, suffix)
|
|
316
|
+
suffix += 1
|
|
317
|
+
used_stems.add(stem.lower())
|
|
318
|
+
return os.path.join(output_dir, "%s.%s" % (stem, image_format))
|
|
319
|
+
|
|
320
|
+
|
|
217
321
|
def import_model(payload, output_files, warnings):
|
|
218
322
|
model_path = payload.get("modelPath")
|
|
219
323
|
output_scene_path = payload.get("outputScenePath")
|
|
@@ -298,6 +402,7 @@ def set_camera(payload, output_files, warnings):
|
|
|
298
402
|
|
|
299
403
|
if position is None or look_at is None:
|
|
300
404
|
raise RuntimeError("position and lookAt are required")
|
|
405
|
+
direction = tuple(look_at[index] - position[index] for index in range(3))
|
|
301
406
|
|
|
302
407
|
camera = first_camera_object(
|
|
303
408
|
lambda: lux.getCamera(camera_name),
|
|
@@ -305,39 +410,53 @@ def set_camera(payload, output_files, warnings):
|
|
|
305
410
|
lambda: lux.createCamera(camera_name),
|
|
306
411
|
)
|
|
307
412
|
|
|
308
|
-
if camera is None and hasattr(lux, "saveCamera"):
|
|
309
|
-
try:
|
|
310
|
-
camera = first_camera_object(
|
|
311
|
-
lambda: lux.saveCamera(camera_name),
|
|
312
|
-
lambda: lux.saveCamera(),
|
|
313
|
-
)
|
|
314
|
-
except RuntimeError:
|
|
315
|
-
camera = None
|
|
316
|
-
|
|
317
413
|
if camera is not None:
|
|
318
414
|
# Object-level API is available: drive the camera object directly.
|
|
319
|
-
call_variants("set camera position", lambda: camera.setPosition(tuple(position)))
|
|
320
415
|
call_variants("set camera look-at", lambda: camera.setLookAt(tuple(look_at)))
|
|
416
|
+
call_variants("set camera position", lambda: camera.setPosition(tuple(position)))
|
|
417
|
+
if hasattr(camera, "setDirection"):
|
|
418
|
+
call_variants("set camera direction", lambda: camera.setDirection(direction))
|
|
321
419
|
call_variants("set camera up", lambda: camera.setUp(tuple(up)))
|
|
322
420
|
else:
|
|
323
421
|
# No camera object could be obtained: fall back to the lux-level setters,
|
|
324
|
-
# which
|
|
325
|
-
#
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
lambda: lux.
|
|
329
|
-
lambda: lux.setCameraPosition(tuple(position)),
|
|
330
|
-
)
|
|
422
|
+
# which operate on the active camera. Existing named cameras are activated
|
|
423
|
+
# first. Missing cameras are saved under their new name after the transform
|
|
424
|
+
# is set; saveCamera is a snapshot operation, not a camera constructor.
|
|
425
|
+
if camera_name in camera_names() and hasattr(lux, "setCamera"):
|
|
426
|
+
call_variants("activate camera", lambda: lux.setCamera(camera_name))
|
|
331
427
|
call_variants(
|
|
332
428
|
"set camera look-at",
|
|
429
|
+
lambda: lux.setCameraLookAt(pt=tuple(look_at)),
|
|
430
|
+
lambda: lux.setCameraLookAt(0, tuple(look_at)),
|
|
333
431
|
lambda: lux.setCameraLookAt(camera_name, tuple(look_at)),
|
|
334
|
-
lambda: lux.setCameraLookAt(tuple(look_at)),
|
|
335
432
|
)
|
|
433
|
+
call_variants(
|
|
434
|
+
"set camera position",
|
|
435
|
+
lambda: lux.setCameraPosition(pos=tuple(position)),
|
|
436
|
+
lambda: lux.setCameraPosition(tuple(position)),
|
|
437
|
+
lambda: lux.setCameraPosition(camera_name, tuple(position)),
|
|
438
|
+
)
|
|
439
|
+
if hasattr(lux, "setCameraDirection"):
|
|
440
|
+
call_variants(
|
|
441
|
+
"set camera direction",
|
|
442
|
+
lambda: lux.setCameraDirection(dir=direction),
|
|
443
|
+
lambda: lux.setCameraDirection(direction),
|
|
444
|
+
)
|
|
336
445
|
call_variants(
|
|
337
446
|
"set camera up",
|
|
338
|
-
lambda: lux.setCameraUp(
|
|
447
|
+
lambda: lux.setCameraUp(up=tuple(up)),
|
|
339
448
|
lambda: lux.setCameraUp(tuple(up)),
|
|
449
|
+
lambda: lux.setCameraUp(camera_name, tuple(up)),
|
|
340
450
|
)
|
|
451
|
+
if hasattr(lux, "saveCamera"):
|
|
452
|
+
try:
|
|
453
|
+
call_variants(
|
|
454
|
+
"save named camera",
|
|
455
|
+
lambda: lux.saveCamera(),
|
|
456
|
+
lambda: lux.saveCamera(camera_name),
|
|
457
|
+
)
|
|
458
|
+
except RuntimeError:
|
|
459
|
+
warnings.append("The active camera was updated, but this KeyShot version could not save the named view.")
|
|
341
460
|
|
|
342
461
|
save_to(output_scene_path)
|
|
343
462
|
output_files.append(output_scene_path)
|
package/server.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
+
"name": "io.github.truman-t3/keyshot-mcp",
|
|
4
|
+
"title": "KeyShot MCP",
|
|
5
|
+
"description": "Control KeyShot Studio through local headless scripting with MCP.",
|
|
6
|
+
"websiteUrl": "https://github.com/truman-t3/keyshot-mcp",
|
|
7
|
+
"repository": {
|
|
8
|
+
"id": "1290741614",
|
|
9
|
+
"url": "https://github.com/truman-t3/keyshot-mcp",
|
|
10
|
+
"source": "github"
|
|
11
|
+
},
|
|
12
|
+
"version": "0.5.0",
|
|
13
|
+
"packages": [
|
|
14
|
+
{
|
|
15
|
+
"registryType": "npm",
|
|
16
|
+
"identifier": "keyshot-mcp",
|
|
17
|
+
"version": "0.5.0",
|
|
18
|
+
"transport": {
|
|
19
|
+
"type": "stdio"
|
|
20
|
+
},
|
|
21
|
+
"environmentVariables": [
|
|
22
|
+
{
|
|
23
|
+
"name": "KEYSHOT_HEADLESS_EXE",
|
|
24
|
+
"description": "Absolute path to KeyShot headless, or a command available on PATH.",
|
|
25
|
+
"isRequired": true,
|
|
26
|
+
"isSecret": false
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
}
|