keyshot-mcp 0.2.1 → 0.4.2
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 +190 -24
- package/assets/demo/keyshot-mcp-demo.png +0 -0
- package/dist/config.js +5 -0
- package/dist/index.js +62 -6
- package/dist/output-paths.js +55 -0
- package/dist/presets.js +67 -0
- package/dist/queue.js +70 -0
- package/dist/runner.js +107 -45
- package/dist/schemas.js +45 -0
- package/dist/version.js +1 -0
- package/examples/demo/README.md +9 -0
- package/examples/demo/keyshot-mcp-cube.obj +23 -0
- package/package.json +12 -3
- package/presets/materials.json +14 -0
- package/scripts/keyshot-smoke.mjs +67 -0
- package/scripts/keyshot_bridge.py +183 -34
- package/server.json +31 -0
package/README.md
CHANGED
|
@@ -61,26 +61,58 @@ This project does not include KeyShot, does not bypass licensing, and does not s
|
|
|
61
61
|
|
|
62
62
|
## Install
|
|
63
63
|
|
|
64
|
+
### Install the published npm package
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
npm install -g keyshot-mcp
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The current release line is `0.4.2`. The published package is the easiest option
|
|
71
|
+
when you only want to use the MCP server. You still need KeyShot Studio and a
|
|
72
|
+
local KeyShot license.
|
|
73
|
+
|
|
74
|
+
### Install from source
|
|
75
|
+
|
|
64
76
|
```bash
|
|
65
77
|
npm install
|
|
66
78
|
npm run build
|
|
67
79
|
```
|
|
68
80
|
|
|
81
|
+
### Three MCP configuration options
|
|
82
|
+
|
|
83
|
+
Use exactly one of these approaches:
|
|
84
|
+
|
|
85
|
+
**Global npm installation** (`npm install -g keyshot-mcp`):
|
|
86
|
+
|
|
87
|
+
```json
|
|
88
|
+
{"mcpServers":{"keyshot":{"command":"keyshot-mcp","env":{"KEYSHOT_HEADLESS_EXE":"/absolute/path/to/keyshot_headless"}}}}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**No installation, run with npx**:
|
|
92
|
+
|
|
93
|
+
```json
|
|
94
|
+
{"mcpServers":{"keyshot":{"command":"npx","args":["-y","keyshot-mcp@0.4.2"],"env":{"KEYSHOT_HEADLESS_EXE":"/absolute/path/to/keyshot_headless"}}}}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
**Run from a cloned source directory** (after `npm install && npm run build`):
|
|
98
|
+
|
|
99
|
+
```json
|
|
100
|
+
{"mcpServers":{"keyshot":{"command":"node","args":["/absolute/path/to/keyshot-mcp/dist/index.js"],"env":{"KEYSHOT_HEADLESS_EXE":"/absolute/path/to/keyshot_headless"}}}}
|
|
101
|
+
```
|
|
102
|
+
|
|
69
103
|
## Copy-Paste Setup Prompt for an Agent
|
|
70
104
|
|
|
71
105
|
If you use Codex or another coding agent, you can copy this prompt and let the agent install the MCP server for you:
|
|
72
106
|
|
|
73
107
|
```text
|
|
74
|
-
Install
|
|
108
|
+
Install KeyShot MCP 0.4.2 from npm and configure it in my MCP client.
|
|
75
109
|
|
|
76
110
|
Please:
|
|
77
|
-
1.
|
|
78
|
-
2.
|
|
79
|
-
3.
|
|
80
|
-
4.
|
|
81
|
-
5.
|
|
82
|
-
6. Test the setup by running the keyshot_status tool.
|
|
83
|
-
7. Tell me the exact config that was added and whether the status check passed.
|
|
111
|
+
1. Find my KeyShot headless executable path.
|
|
112
|
+
2. Add a keyshot MCP server using npx -y keyshot-mcp@0.4.2.
|
|
113
|
+
3. Set KEYSHOT_HEADLESS_EXE to the detected keyshot_headless path.
|
|
114
|
+
4. Test the setup by running the keyshot_status tool.
|
|
115
|
+
5. Tell me the exact config that was added and whether the status check passed.
|
|
84
116
|
|
|
85
117
|
Do not store license keys or account passwords. Use my existing local KeyShot license configuration.
|
|
86
118
|
```
|
|
@@ -165,25 +197,58 @@ Import the model file at /path/to/model.step, apply a brushed metal material to
|
|
|
165
197
|
Inspect this KeyShot scene and summarize the available objects, cameras, materials, and renderable outputs.
|
|
166
198
|
```
|
|
167
199
|
|
|
200
|
+
```text
|
|
201
|
+
List the cameras in this KeyShot scene, create or update a camera named "Hero",
|
|
202
|
+
save the scene to a new file, and render one preview from that camera.
|
|
203
|
+
```
|
|
204
|
+
|
|
168
205
|
## Environment Variables
|
|
169
206
|
|
|
170
207
|
- `KEYSHOT_HEADLESS_EXE`: path to `keyshot_headless` or `keyshot_headless.exe`.
|
|
171
208
|
- `KEYSHOT_OUTPUT_DIR`: default output folder for renders.
|
|
172
209
|
- `KEYSHOT_LICENSE_ARGS`: optional KeyShot headless license arguments. Empty by default.
|
|
173
210
|
- `KEYSHOT_TIMEOUT_MS`: operation timeout in milliseconds. Default: `600000`.
|
|
211
|
+
- `KEYSHOT_ALLOW_EXTERNAL_OUTPUTS`: allow output paths outside `KEYSHOT_OUTPUT_DIR`. Default: `false`.
|
|
212
|
+
|
|
213
|
+
By default every output file must stay inside `KEYSHOT_OUTPUT_DIR`. Relative paths
|
|
214
|
+
are placed there automatically. Set the compatibility switch to `true` only when
|
|
215
|
+
an external output location is intentional; input scenes and models are unrestricted.
|
|
216
|
+
|
|
217
|
+
`samples` and `maxTimeSeconds` select different KeyShot render modes. Provide one
|
|
218
|
+
or the other, not both.
|
|
174
219
|
|
|
175
220
|
## MCP Tools
|
|
176
221
|
|
|
177
222
|
- `keyshot_status`
|
|
178
223
|
- `keyshot_inspect_scene`
|
|
224
|
+
- `keyshot_list_cameras`: list available camera names in a scene (handy before batch rendering)
|
|
179
225
|
- `keyshot_render`: render a single image
|
|
180
226
|
- `keyshot_batch_render`
|
|
227
|
+
- `keyshot_render_queue`: run several render jobs sequentially (stops at first failure unless `continueOnError`)
|
|
181
228
|
- `keyshot_import_model`
|
|
182
229
|
- `keyshot_apply_material`
|
|
230
|
+
- `keyshot_list_material_presets`: list presets from the material preset library
|
|
231
|
+
- `keyshot_apply_material_preset`: apply a named preset from the library to an object
|
|
183
232
|
- `keyshot_set_camera`
|
|
184
233
|
- `keyshot_set_environment`
|
|
185
234
|
- `keyshot_save_scene`
|
|
186
235
|
|
|
236
|
+
### Material preset library
|
|
237
|
+
|
|
238
|
+
`keyshot_apply_material_preset` reads a small JSON registry so you can reuse named
|
|
239
|
+
looks instead of remembering exact KeyShot material names. Default location:
|
|
240
|
+
`presets/materials.json` (override with the `KEYSHOT_MATERIAL_PRESETS` env var).
|
|
241
|
+
|
|
242
|
+
```json
|
|
243
|
+
{
|
|
244
|
+
"Brushed Steel": { "materialName": "Steel Brushed", "description": "metal parts" },
|
|
245
|
+
"Clear Glass": { "materialPath": "C:/materials/glass_clear.mtl" }
|
|
246
|
+
}
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Each preset must have a `materialName` or a `materialPath`. Use
|
|
250
|
+
`keyshot_list_material_presets` to see what is available.
|
|
251
|
+
|
|
187
252
|
## MCP Prompts and Resources
|
|
188
253
|
|
|
189
254
|
- Prompt: `keyshot_product_render`
|
|
@@ -205,13 +270,24 @@ KeyShot's Python `lux` API changes across versions. This server keeps the MCP in
|
|
|
205
270
|
## Roadmap
|
|
206
271
|
|
|
207
272
|
- [ ] Auto-discover and batch render all cameras in a scene
|
|
208
|
-
- [
|
|
273
|
+
- [x] Material preset library
|
|
209
274
|
- [ ] Camera preset templates
|
|
210
|
-
- [
|
|
211
|
-
- [
|
|
275
|
+
- [x] Sequential render queue
|
|
276
|
+
- [x] Safer output directory restrictions
|
|
212
277
|
- [ ] More tested KeyShot versions
|
|
213
278
|
- [ ] macOS compatibility verification
|
|
214
|
-
- [
|
|
279
|
+
- [x] Claude Desktop / Cursor / Codex config examples
|
|
280
|
+
|
|
281
|
+
## Real KeyShot demo
|
|
282
|
+
|
|
283
|
+

|
|
284
|
+
|
|
285
|
+
The demo uses only the generated geometry in `examples/demo`. To reproduce the
|
|
286
|
+
status, import, inspect, camera, save, and PNG-render workflow locally:
|
|
287
|
+
|
|
288
|
+
```bash
|
|
289
|
+
npm run smoke:keyshot
|
|
290
|
+
```
|
|
215
291
|
|
|
216
292
|
## License
|
|
217
293
|
|
|
@@ -278,26 +354,57 @@ MIT
|
|
|
278
354
|
|
|
279
355
|
## 安装
|
|
280
356
|
|
|
357
|
+
### 安装已发布的 npm 包
|
|
358
|
+
|
|
359
|
+
```bash
|
|
360
|
+
npm install -g keyshot-mcp
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
当前发布版本为 `0.4.2`。如果你只是想使用 MCP 服务,直接安装 npm 包最简单。
|
|
364
|
+
电脑仍需要安装 KeyShot Studio,并且已经配置好本地 KeyShot 授权。
|
|
365
|
+
|
|
366
|
+
### 从源码安装
|
|
367
|
+
|
|
281
368
|
```bash
|
|
282
369
|
npm install
|
|
283
370
|
npm run build
|
|
284
371
|
```
|
|
285
372
|
|
|
373
|
+
### 三种独立配置方式
|
|
374
|
+
|
|
375
|
+
下面三种方式选择一种即可:
|
|
376
|
+
|
|
377
|
+
**全局 npm 安装**(先运行 `npm install -g keyshot-mcp`):
|
|
378
|
+
|
|
379
|
+
```json
|
|
380
|
+
{"mcpServers":{"keyshot":{"command":"keyshot-mcp","env":{"KEYSHOT_HEADLESS_EXE":"C:/KeyShot/bin/keyshot_headless.exe"}}}}
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
**免安装,直接使用 npx**:
|
|
384
|
+
|
|
385
|
+
```json
|
|
386
|
+
{"mcpServers":{"keyshot":{"command":"npx","args":["-y","keyshot-mcp@0.4.2"],"env":{"KEYSHOT_HEADLESS_EXE":"C:/KeyShot/bin/keyshot_headless.exe"}}}}
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
**使用本地源码**(先运行 `npm install && npm run build`):
|
|
390
|
+
|
|
391
|
+
```json
|
|
392
|
+
{"mcpServers":{"keyshot":{"command":"node","args":["C:/path/to/keyshot-mcp/dist/index.js"],"env":{"KEYSHOT_HEADLESS_EXE":"C:/KeyShot/bin/keyshot_headless.exe"}}}}
|
|
393
|
+
```
|
|
394
|
+
|
|
286
395
|
## 复制给 Agent 的安装提示词
|
|
287
396
|
|
|
288
397
|
如果你使用 Codex 或其他编程 Agent,可以复制下面这段话,让 Agent 帮你安装和配置 MCP:
|
|
289
398
|
|
|
290
399
|
```text
|
|
291
|
-
请帮我安装 KeyShot MCP
|
|
400
|
+
请帮我安装 KeyShot MCP 0.4.2,并添加到我的 MCP 客户端。
|
|
292
401
|
|
|
293
402
|
请你:
|
|
294
|
-
1.
|
|
295
|
-
2.
|
|
296
|
-
3.
|
|
297
|
-
4.
|
|
298
|
-
5.
|
|
299
|
-
6. 用 keyshot_status 工具测试是否配置成功。
|
|
300
|
-
7. 最后告诉我实际添加的配置,以及状态检查是否通过。
|
|
403
|
+
1. 查找我电脑上的 KeyShot headless 可执行文件路径。
|
|
404
|
+
2. 使用 npx -y keyshot-mcp@0.4.2 添加 keyshot MCP server。
|
|
405
|
+
3. 把 KEYSHOT_HEADLESS_EXE 设置为检测到的 keyshot_headless 路径。
|
|
406
|
+
4. 用 keyshot_status 工具测试是否配置成功。
|
|
407
|
+
5. 最后告诉我实际添加的配置,以及状态检查是否通过。
|
|
301
408
|
|
|
302
409
|
不要保存许可证密钥、账号密码或授权信息。直接使用我电脑上已有的 KeyShot 本地授权配置。
|
|
303
410
|
```
|
|
@@ -382,25 +489,56 @@ examples/codex.example.json
|
|
|
382
489
|
检查当前 KeyShot 场景,并总结场景中的对象、相机、材质和可渲染输出。
|
|
383
490
|
```
|
|
384
491
|
|
|
492
|
+
```text
|
|
493
|
+
列出当前 KeyShot 场景中的相机,创建或更新一个名为“Hero”的相机,
|
|
494
|
+
把场景保存为新文件,并用这个相机渲染一张预览图。
|
|
495
|
+
```
|
|
496
|
+
|
|
385
497
|
## 环境变量
|
|
386
498
|
|
|
387
499
|
- `KEYSHOT_HEADLESS_EXE`:`keyshot_headless` 或 `keyshot_headless.exe` 的路径。
|
|
388
500
|
- `KEYSHOT_OUTPUT_DIR`:默认渲染输出文件夹。
|
|
389
501
|
- `KEYSHOT_LICENSE_ARGS`:可选的 KeyShot 无界面许可证参数,默认留空。
|
|
390
502
|
- `KEYSHOT_TIMEOUT_MS`:单次操作超时时间,单位毫秒,默认 `600000`。
|
|
503
|
+
- `KEYSHOT_MATERIAL_PRESETS`:材质预设库 JSON 文件路径,默认 `presets/materials.json`。
|
|
504
|
+
- `KEYSHOT_ALLOW_EXTERNAL_OUTPUTS`:是否允许写到默认输出目录之外,默认 `false`。
|
|
505
|
+
|
|
506
|
+
默认情况下,所有输出图片和场景都必须写入 `KEYSHOT_OUTPUT_DIR`,相对路径会自动放入该目录。
|
|
507
|
+
只有确实需要写到外部目录时才设置为 `true`;输入场景和模型路径不受此限制。
|
|
508
|
+
|
|
509
|
+
`samples` 和 `maxTimeSeconds` 是两种不同的 KeyShot 渲染模式,请二选一,不要同时传入。
|
|
391
510
|
|
|
392
511
|
## MCP 工具
|
|
393
512
|
|
|
394
513
|
- `keyshot_status`:检查 KeyShot 是否能启动。
|
|
395
514
|
- `keyshot_inspect_scene`:检查场景内容。
|
|
515
|
+
- `keyshot_list_cameras`:列出场景中所有相机名称(批量渲染前很有用)。
|
|
396
516
|
- `keyshot_render`:渲染单张图片。
|
|
397
517
|
- `keyshot_batch_render`:批量渲染多个相机视角。
|
|
518
|
+
- `keyshot_render_queue`:顺序执行多个渲染任务(默认遇错即停,设置 `continueOnError` 可继续)。
|
|
398
519
|
- `keyshot_import_model`:导入模型。
|
|
399
520
|
- `keyshot_apply_material`:替换材质。
|
|
521
|
+
- `keyshot_list_material_presets`:列出材质预设库中的预设。
|
|
522
|
+
- `keyshot_apply_material_preset`:把预设库中的某个命名材质应用到物体上。
|
|
400
523
|
- `keyshot_set_camera`:设置相机。
|
|
401
524
|
- `keyshot_set_environment`:设置环境。
|
|
402
525
|
- `keyshot_save_scene`:保存场景。
|
|
403
526
|
|
|
527
|
+
### 材质预设库
|
|
528
|
+
|
|
529
|
+
`keyshot_apply_material_preset` 会读取一个小的 JSON 注册表,让你用"好记的名字"复用材质,
|
|
530
|
+
而不必记住 KeyShot 里精确的材质名。默认位置:`presets/materials.json`
|
|
531
|
+
(可用环境变量 `KEYSHOT_MATERIAL_PRESETS` 覆盖)。
|
|
532
|
+
|
|
533
|
+
```json
|
|
534
|
+
{
|
|
535
|
+
"拉丝钢": { "materialName": "Steel Brushed", "description": "金属件默认材质" },
|
|
536
|
+
"透明玻璃": { "materialPath": "C:/materials/glass_clear.mtl" }
|
|
537
|
+
}
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
每个预设必须包含 `materialName` 或 `materialPath`。用 `keyshot_list_material_presets` 查看有哪些预设。
|
|
541
|
+
|
|
404
542
|
## MCP 提示词和资源
|
|
405
543
|
|
|
406
544
|
- 提示词:`keyshot_product_render`
|
|
@@ -422,13 +560,41 @@ KeyShot 的 Python `lux` API 会随版本变化。这个 MCP 会尽量保持对
|
|
|
422
560
|
## 路线图
|
|
423
561
|
|
|
424
562
|
- [ ] 自动发现并批量渲染场景中的所有相机
|
|
425
|
-
- [
|
|
563
|
+
- [x] 材质预设库
|
|
426
564
|
- [ ] 相机预设模板
|
|
427
|
-
- [
|
|
428
|
-
- [
|
|
565
|
+
- [x] 顺序渲染队列
|
|
566
|
+
- [x] 更安全的输出目录限制
|
|
429
567
|
- [ ] 测试更多 KeyShot 版本
|
|
430
568
|
- [ ] 验证 macOS 兼容性
|
|
431
|
-
- [
|
|
569
|
+
- [x] 补充 Claude Desktop / Cursor / Codex 配置示例
|
|
570
|
+
|
|
571
|
+
## 真实 KeyShot Demo
|
|
572
|
+
|
|
573
|
+

|
|
574
|
+
|
|
575
|
+
Demo 只使用 `examples/demo` 中项目自己生成的几何体,不包含客户模型或第三方素材。
|
|
576
|
+
设置好 `KEYSHOT_HEADLESS_EXE` 后可复现状态检查、导入、检查场景、创建相机、保存和真实 PNG 渲染:
|
|
577
|
+
|
|
578
|
+
```bash
|
|
579
|
+
npm run smoke:keyshot
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
## 测试
|
|
583
|
+
|
|
584
|
+
项目带了两套测试,不依赖真实的 KeyShot 也能跑:
|
|
585
|
+
|
|
586
|
+
- **TypeScript 测试(Vitest)**:覆盖配置读取、参数校验(schemas)、结果封装,以及用"假 KeyShot"跑通一次成功路径并验证 `work/tmp` 临时文件会被清理。
|
|
587
|
+
|
|
588
|
+
```bash
|
|
589
|
+
npm install
|
|
590
|
+
npm test
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
- **Python bridge 测试(unittest)**:用假的 `lux` 对象验证 `import_model` 会真正打开基础场景、`set_camera` 在拿不到相机对象时不会崩溃并回退到 `lux` 级 API。
|
|
594
|
+
|
|
595
|
+
```bash
|
|
596
|
+
python tests/test_bridge.py
|
|
597
|
+
```
|
|
432
598
|
|
|
433
599
|
## 开源协议
|
|
434
600
|
|
|
Binary file
|
package/dist/config.js
CHANGED
|
@@ -9,12 +9,17 @@ 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"),
|
|
15
16
|
bridgeScriptPath: path.join(projectRoot, "scripts", "keyshot_bridge.py"),
|
|
17
|
+
materialPresetsPath: path.resolve(process.env.KEYSHOT_MATERIAL_PRESETS ?? path.join(projectRoot, "presets", "materials.json")),
|
|
16
18
|
};
|
|
17
19
|
}
|
|
20
|
+
function parseBoolean(value) {
|
|
21
|
+
return ["1", "true", "yes", "on"].includes((value ?? "").trim().toLowerCase());
|
|
22
|
+
}
|
|
18
23
|
function parsePositiveInt(value, fallback) {
|
|
19
24
|
if (!value)
|
|
20
25
|
return fallback;
|
package/dist/index.js
CHANGED
|
@@ -2,14 +2,20 @@
|
|
|
2
2
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
4
|
import { getConfig } from "./config.js";
|
|
5
|
-
import { toolResponse } from "./result.js";
|
|
5
|
+
import { toolResponse, localFailure } from "./result.js";
|
|
6
6
|
import { runKeyShotSerialized } from "./runner.js";
|
|
7
|
-
import {
|
|
7
|
+
import { runRenderQueue } from "./queue.js";
|
|
8
|
+
import { loadMaterialPresets, findMaterialPreset } from "./presets.js";
|
|
9
|
+
import { VERSION } from "./version.js";
|
|
10
|
+
import { applyMaterialSchema, applyMaterialInputSchema, applyMaterialPresetInputSchema, applyMaterialPresetSchema, batchRenderSchema, batchRenderInputSchema, importModelSchema, listCamerasSchema, listMaterialPresetsSchema, renderQueueSchema, renderQueueInputSchema, renderSchema, renderInputSchema, saveSceneSchema, scenePathSchema, setCameraSchema, setEnvironmentSchema, } from "./schemas.js";
|
|
8
11
|
const config = getConfig();
|
|
9
12
|
const server = new McpServer({
|
|
10
13
|
name: "keyshot-mcp",
|
|
11
|
-
version:
|
|
14
|
+
version: VERSION,
|
|
12
15
|
});
|
|
16
|
+
function errorMessage(error) {
|
|
17
|
+
return error instanceof Error ? error.message : String(error);
|
|
18
|
+
}
|
|
13
19
|
server.registerResource("keyshot-workflow", "keyshot://workflow", {
|
|
14
20
|
title: "KeyShot MCP Workflow",
|
|
15
21
|
description: "How this MCP server connects AI agents to KeyShot headless scripting.",
|
|
@@ -53,17 +59,67 @@ server.registerPrompt("keyshot_product_render", {
|
|
|
53
59
|
}));
|
|
54
60
|
server.tool("keyshot_status", "Check KeyShot headless availability and script startup.", {}, async () => toolResponse(await runKeyShotSerialized(config, { operation: "status" })));
|
|
55
61
|
server.tool("keyshot_inspect_scene", "Open a KeyShot scene and return available objects, cameras, materials and scene metadata.", scenePathSchema.shape, async (args) => toolResponse(await runKeyShotSerialized(config, { operation: "inspect_scene", ...args })));
|
|
56
|
-
server.tool("
|
|
57
|
-
server.tool("
|
|
62
|
+
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 })));
|
|
63
|
+
server.tool("keyshot_render", "Render a KeyShot scene to an image file.", renderSchema.shape, async (args) => {
|
|
64
|
+
const parsed = renderInputSchema.parse(args);
|
|
65
|
+
return toolResponse(await runKeyShotSerialized(config, { operation: "render", ...parsed }));
|
|
66
|
+
});
|
|
67
|
+
server.tool("keyshot_render_queue", "Render several jobs sequentially. Stops at the first failure unless continueOnError is set.", renderQueueSchema.shape, async (args) => {
|
|
68
|
+
const parsed = renderQueueInputSchema.parse(args);
|
|
69
|
+
return toolResponse(await runRenderQueue(config, parsed.jobs, { continueOnError: parsed.continueOnError ?? false }));
|
|
70
|
+
});
|
|
71
|
+
server.tool("keyshot_batch_render", "Render multiple named cameras from one KeyShot scene into an output directory.", batchRenderSchema.shape, async (args) => {
|
|
72
|
+
const parsed = batchRenderInputSchema.parse(args);
|
|
73
|
+
return toolResponse(await runKeyShotSerialized(config, { operation: "batch_render", ...parsed }));
|
|
74
|
+
});
|
|
58
75
|
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, {
|
|
59
76
|
operation: "import_model",
|
|
60
|
-
scenePath: args.baseScenePath,
|
|
61
77
|
...args,
|
|
62
78
|
})));
|
|
63
79
|
server.tool("keyshot_apply_material", "Apply a material by name or material file to a scene object and save the resulting scene.", applyMaterialInputSchema.shape, async (args) => {
|
|
64
80
|
const parsed = applyMaterialSchema.parse(args);
|
|
65
81
|
return toolResponse(await runKeyShotSerialized(config, { operation: "apply_material", ...parsed }));
|
|
66
82
|
});
|
|
83
|
+
server.tool("keyshot_list_material_presets", "List material presets from the local preset library (presets/materials.json or KEYSHOT_MATERIAL_PRESETS).", listMaterialPresetsSchema.shape, async () => {
|
|
84
|
+
try {
|
|
85
|
+
const presets = await loadMaterialPresets(config);
|
|
86
|
+
return toolResponse({
|
|
87
|
+
ok: true,
|
|
88
|
+
data: { presets, count: presets.length, source: config.materialPresetsPath },
|
|
89
|
+
outputFiles: [],
|
|
90
|
+
warnings: presets.length === 0 ? ["No material presets found. Create presets/materials.json to add some."] : [],
|
|
91
|
+
keyshotStdoutTail: "",
|
|
92
|
+
error: null,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
return toolResponse(localFailure(errorMessage(error)));
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
server.tool("keyshot_apply_material_preset", "Apply a named material preset (from the preset library) to a scene object and save the resulting scene.", applyMaterialPresetInputSchema.shape, async (args) => {
|
|
100
|
+
const parsed = applyMaterialPresetSchema.parse(args);
|
|
101
|
+
let presets;
|
|
102
|
+
try {
|
|
103
|
+
presets = await loadMaterialPresets(config);
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
return toolResponse(localFailure(errorMessage(error)));
|
|
107
|
+
}
|
|
108
|
+
const preset = findMaterialPreset(presets, parsed.presetName);
|
|
109
|
+
if (!preset) {
|
|
110
|
+
const available = presets.map((entry) => entry.name).join(", ") || "(none)";
|
|
111
|
+
return toolResponse(localFailure(`Material preset not found: "${parsed.presetName}". Available: ${available}`));
|
|
112
|
+
}
|
|
113
|
+
return toolResponse(await runKeyShotSerialized(config, {
|
|
114
|
+
operation: "apply_material",
|
|
115
|
+
scenePath: parsed.scenePath,
|
|
116
|
+
objectName: parsed.objectName,
|
|
117
|
+
objectPath: parsed.objectPath,
|
|
118
|
+
materialName: preset.materialName,
|
|
119
|
+
materialPath: preset.materialPath,
|
|
120
|
+
outputScenePath: parsed.outputScenePath,
|
|
121
|
+
}));
|
|
122
|
+
});
|
|
67
123
|
server.tool("keyshot_set_camera", "Create or update a camera from position/look-at vectors and save the resulting scene.", setCameraSchema.shape, async (args) => toolResponse(await runKeyShotSerialized(config, { operation: "set_camera", ...args })));
|
|
68
124
|
server.tool("keyshot_set_environment", "Set a scene environment by name or file when supported by KeyShot headless scripting.", setEnvironmentSchema.shape, async (args) => toolResponse(await runKeyShotSerialized(config, { operation: "set_environment", ...args })));
|
|
69
125
|
server.tool("keyshot_save_scene", "Save a KeyShot scene to a new path.", saveSceneSchema.shape, async (args) => toolResponse(await runKeyShotSerialized(config, { operation: "save_scene", ...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/presets.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
/**
|
|
3
|
+
* Load the material preset library from disk.
|
|
4
|
+
*
|
|
5
|
+
* The registry file (see config.materialPresetsPath, overridable with the
|
|
6
|
+
* KEYSHOT_MATERIAL_PRESETS env var) may use either of two shapes:
|
|
7
|
+
*
|
|
8
|
+
* 1. Object keyed by preset name:
|
|
9
|
+
* { "Brushed Steel": { "materialName": "Steel Brushed", "description": "..." } }
|
|
10
|
+
*
|
|
11
|
+
* 2. Array of presets:
|
|
12
|
+
* [ { "name": "Brushed Steel", "materialName": "Steel Brushed" } ]
|
|
13
|
+
*
|
|
14
|
+
* A missing file is treated as an empty library (not an error), so the feature
|
|
15
|
+
* works out of the box before the user creates any presets.
|
|
16
|
+
*/
|
|
17
|
+
export async function loadMaterialPresets(config) {
|
|
18
|
+
let raw;
|
|
19
|
+
try {
|
|
20
|
+
raw = await fs.readFile(config.materialPresetsPath, "utf8");
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
let parsed;
|
|
26
|
+
try {
|
|
27
|
+
parsed = JSON.parse(raw);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
throw new Error(`Material presets file is not valid JSON: ${config.materialPresetsPath}`);
|
|
31
|
+
}
|
|
32
|
+
return normalizePresets(parsed);
|
|
33
|
+
}
|
|
34
|
+
export function normalizePresets(parsed) {
|
|
35
|
+
const entries = [];
|
|
36
|
+
if (Array.isArray(parsed)) {
|
|
37
|
+
for (const item of parsed) {
|
|
38
|
+
if (item && typeof item === "object") {
|
|
39
|
+
const record = item;
|
|
40
|
+
const name = typeof record.name === "string" ? record.name : undefined;
|
|
41
|
+
if (name)
|
|
42
|
+
entries.push(toPreset(name, record));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
else if (parsed && typeof parsed === "object") {
|
|
47
|
+
for (const [name, value] of Object.entries(parsed)) {
|
|
48
|
+
if (value && typeof value === "object") {
|
|
49
|
+
entries.push(toPreset(name, value));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return entries.filter((preset) => preset.materialName || preset.materialPath);
|
|
54
|
+
}
|
|
55
|
+
function toPreset(name, record) {
|
|
56
|
+
return {
|
|
57
|
+
name,
|
|
58
|
+
materialName: typeof record.materialName === "string" ? record.materialName : undefined,
|
|
59
|
+
materialPath: typeof record.materialPath === "string" ? record.materialPath : undefined,
|
|
60
|
+
description: typeof record.description === "string" ? record.description : undefined,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
export function findMaterialPreset(presets, name) {
|
|
64
|
+
const lower = name.toLowerCase();
|
|
65
|
+
return (presets.find((preset) => preset.name === name) ??
|
|
66
|
+
presets.find((preset) => preset.name.toLowerCase() === lower));
|
|
67
|
+
}
|
package/dist/queue.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { runKeyShotSerialized } from "./runner.js";
|
|
2
|
+
/**
|
|
3
|
+
* Run several render jobs one after another.
|
|
4
|
+
*
|
|
5
|
+
* Jobs execute sequentially (the underlying runner is already serialized, and a
|
|
6
|
+
* single KeyShot instance cannot render in parallel). By default the queue stops
|
|
7
|
+
* at the first failure; pass continueOnError to render every remaining job and
|
|
8
|
+
* collect all outcomes. Jobs that are skipped after an early stop are reported
|
|
9
|
+
* with skipped: true so the caller can see exactly what ran.
|
|
10
|
+
*/
|
|
11
|
+
export async function runRenderQueue(config, jobs, options = {}, runFn = runKeyShotSerialized) {
|
|
12
|
+
const continueOnError = options.continueOnError ?? false;
|
|
13
|
+
const results = [];
|
|
14
|
+
const outputFiles = [];
|
|
15
|
+
const warnings = [];
|
|
16
|
+
let succeeded = 0;
|
|
17
|
+
let failed = 0;
|
|
18
|
+
let stopped = false;
|
|
19
|
+
for (let index = 0; index < jobs.length; index += 1) {
|
|
20
|
+
const job = jobs[index];
|
|
21
|
+
if (stopped) {
|
|
22
|
+
results.push({
|
|
23
|
+
index,
|
|
24
|
+
scenePath: job.scenePath,
|
|
25
|
+
camera: job.camera,
|
|
26
|
+
ok: false,
|
|
27
|
+
outputFiles: [],
|
|
28
|
+
error: null,
|
|
29
|
+
skipped: true,
|
|
30
|
+
});
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
const result = await runFn(config, { operation: "render", ...job });
|
|
34
|
+
results.push({
|
|
35
|
+
index,
|
|
36
|
+
scenePath: job.scenePath,
|
|
37
|
+
camera: job.camera,
|
|
38
|
+
ok: result.ok,
|
|
39
|
+
outputFiles: result.outputFiles ?? [],
|
|
40
|
+
error: result.error ?? null,
|
|
41
|
+
});
|
|
42
|
+
if (result.ok) {
|
|
43
|
+
succeeded += 1;
|
|
44
|
+
outputFiles.push(...(result.outputFiles ?? []));
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
failed += 1;
|
|
48
|
+
if (result.error)
|
|
49
|
+
warnings.push(`Job ${index} failed: ${result.error}`);
|
|
50
|
+
if (!continueOnError)
|
|
51
|
+
stopped = true;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const skipped = results.filter((entry) => entry.skipped).length;
|
|
55
|
+
return {
|
|
56
|
+
ok: failed === 0,
|
|
57
|
+
data: {
|
|
58
|
+
total: jobs.length,
|
|
59
|
+
succeeded,
|
|
60
|
+
failed,
|
|
61
|
+
skipped,
|
|
62
|
+
continueOnError,
|
|
63
|
+
results,
|
|
64
|
+
},
|
|
65
|
+
outputFiles,
|
|
66
|
+
warnings,
|
|
67
|
+
keyshotStdoutTail: "",
|
|
68
|
+
error: failed === 0 ? null : `${failed} of ${jobs.length} render job(s) failed.`,
|
|
69
|
+
};
|
|
70
|
+
}
|