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 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 the KeyShot MCP server from https://github.com/truman-t3/keyshot-mcp.
108
+ Install KeyShot MCP 0.4.2 from npm and configure it in my MCP client.
75
109
 
76
110
  Please:
77
- 1. Clone the repository or use my local copy if it already exists.
78
- 2. Run npm install and npm run build.
79
- 3. Find my KeyShot headless executable path.
80
- 4. Add a keyshot MCP server entry to my MCP client configuration.
81
- 5. Set KEYSHOT_HEADLESS_EXE to the detected keyshot_headless path.
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
- - [ ] Material preset library
273
+ - [x] Material preset library
209
274
  - [ ] Camera preset templates
210
- - [ ] Render queue status
211
- - [ ] Safer output directory restrictions
275
+ - [x] Sequential render queue
276
+ - [x] Safer output directory restrictions
212
277
  - [ ] More tested KeyShot versions
213
278
  - [ ] macOS compatibility verification
214
- - [ ] Claude Desktop / Cursor / Codex config examples
279
+ - [x] Claude Desktop / Cursor / Codex config examples
280
+
281
+ ## Real KeyShot demo
282
+
283
+ ![KeyShot MCP generated cube demo](assets/demo/keyshot-mcp-demo.png)
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 Server:https://github.com/truman-t3/keyshot-mcp。
400
+ 请帮我安装 KeyShot MCP 0.4.2,并添加到我的 MCP 客户端。
292
401
 
293
402
  请你:
294
- 1. 克隆这个仓库;如果本地已有,就使用本地版本。
295
- 2. 运行 npm install npm run build
296
- 3. 查找我电脑上的 KeyShot headless 可执行文件路径。
297
- 4. keyshot MCP server 添加到我的 MCP 客户端配置里。
298
- 5. 把 KEYSHOT_HEADLESS_EXE 设置为检测到的 keyshot_headless 路径。
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
- - [ ] 补充 Claude Desktop / Cursor / Codex 配置示例
569
+ - [x] 补充 Claude Desktop / Cursor / Codex 配置示例
570
+
571
+ ## 真实 KeyShot Demo
572
+
573
+ ![KeyShot MCP 自动生成的立方体 Demo](assets/demo/keyshot-mcp-demo.png)
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 { applyMaterialSchema, applyMaterialInputSchema, batchRenderSchema, importModelSchema, renderSchema, saveSceneSchema, scenePathSchema, setCameraSchema, setEnvironmentSchema, } from "./schemas.js";
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: "0.2.1",
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("keyshot_render", "Render a KeyShot scene to an image file.", renderSchema.shape, async (args) => toolResponse(await runKeyShotSerialized(config, { operation: "render", ...args })));
57
- server.tool("keyshot_batch_render", "Render multiple named cameras from one KeyShot scene into an output directory.", batchRenderSchema.shape, async (args) => toolResponse(await runKeyShotSerialized(config, { operation: "batch_render", ...args })));
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
+ }
@@ -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
+ }