keyshot-mcp 0.4.2 → 0.6.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 CHANGED
@@ -28,9 +28,11 @@ 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.
35
+ - Apply reusable standard or custom camera presets.
34
36
  - Set an environment when the installed KeyShot version exposes that function.
35
37
  - Save a scene to a new file.
36
38
 
@@ -67,7 +69,7 @@ This project does not include KeyShot, does not bypass licensing, and does not s
67
69
  npm install -g keyshot-mcp
68
70
  ```
69
71
 
70
- The current release line is `0.4.2`. The published package is the easiest option
72
+ The current release line is `0.6.0`. The published package is the easiest option
71
73
  when you only want to use the MCP server. You still need KeyShot Studio and a
72
74
  local KeyShot license.
73
75
 
@@ -91,7 +93,7 @@ Use exactly one of these approaches:
91
93
  **No installation, run with npx**:
92
94
 
93
95
  ```json
94
- {"mcpServers":{"keyshot":{"command":"npx","args":["-y","keyshot-mcp@0.4.2"],"env":{"KEYSHOT_HEADLESS_EXE":"/absolute/path/to/keyshot_headless"}}}}
96
+ {"mcpServers":{"keyshot":{"command":"npx","args":["-y","keyshot-mcp@0.6.0"],"env":{"KEYSHOT_HEADLESS_EXE":"/absolute/path/to/keyshot_headless"}}}}
95
97
  ```
96
98
 
97
99
  **Run from a cloned source directory** (after `npm install && npm run build`):
@@ -105,11 +107,11 @@ Use exactly one of these approaches:
105
107
  If you use Codex or another coding agent, you can copy this prompt and let the agent install the MCP server for you:
106
108
 
107
109
  ```text
108
- Install KeyShot MCP 0.4.2 from npm and configure it in my MCP client.
110
+ Install KeyShot MCP 0.6.0 from npm and configure it in my MCP client.
109
111
 
110
112
  Please:
111
113
  1. Find my KeyShot headless executable path.
112
- 2. Add a keyshot MCP server using npx -y keyshot-mcp@0.4.2.
114
+ 2. Add a keyshot MCP server using npx -y keyshot-mcp@0.6.0.
113
115
  3. Set KEYSHOT_HEADLESS_EXE to the detected keyshot_headless path.
114
116
  4. Test the setup by running the keyshot_status tool.
115
117
  5. Tell me the exact config that was added and whether the status check passed.
@@ -202,6 +204,16 @@ List the cameras in this KeyShot scene, create or update a camera named "Hero",
202
204
  save the scene to a new file, and render one preview from that camera.
203
205
  ```
204
206
 
207
+ ```text
208
+ Find every camera in this KeyShot scene and render all views to the all-cameras
209
+ output folder. Continue if one camera fails and report the result for each view.
210
+ ```
211
+
212
+ ```text
213
+ List the available camera presets, apply the Isometric preset as a camera named
214
+ "Catalog Isometric", save a new scene, and render a preview from that camera.
215
+ ```
216
+
205
217
  ## Environment Variables
206
218
 
207
219
  - `KEYSHOT_HEADLESS_EXE`: path to `keyshot_headless` or `keyshot_headless.exe`.
@@ -209,6 +221,7 @@ save the scene to a new file, and render one preview from that camera.
209
221
  - `KEYSHOT_LICENSE_ARGS`: optional KeyShot headless license arguments. Empty by default.
210
222
  - `KEYSHOT_TIMEOUT_MS`: operation timeout in milliseconds. Default: `600000`.
211
223
  - `KEYSHOT_ALLOW_EXTERNAL_OUTPUTS`: allow output paths outside `KEYSHOT_OUTPUT_DIR`. Default: `false`.
224
+ - `KEYSHOT_CAMERA_PRESETS`: camera preset JSON file. Default: `presets/cameras.json`.
212
225
 
213
226
  By default every output file must stay inside `KEYSHOT_OUTPUT_DIR`. Relative paths
214
227
  are placed there automatically. Set the compatibility switch to `true` only when
@@ -224,15 +237,27 @@ or the other, not both.
224
237
  - `keyshot_list_cameras`: list available camera names in a scene (handy before batch rendering)
225
238
  - `keyshot_render`: render a single image
226
239
  - `keyshot_batch_render`
240
+ - `keyshot_render_all_cameras`: discover and render every camera in a scene; continues after individual failures by default
227
241
  - `keyshot_render_queue`: run several render jobs sequentially (stops at first failure unless `continueOnError`)
228
242
  - `keyshot_import_model`
229
243
  - `keyshot_apply_material`
230
244
  - `keyshot_list_material_presets`: list presets from the material preset library
231
245
  - `keyshot_apply_material_preset`: apply a named preset from the library to an object
232
246
  - `keyshot_set_camera`
247
+ - `keyshot_list_camera_presets`: list standard and custom camera presets
248
+ - `keyshot_apply_camera_preset`: create or update a named camera from a preset
233
249
  - `keyshot_set_environment`
234
250
  - `keyshot_save_scene`
235
251
 
252
+ ### Render every camera automatically
253
+
254
+ `keyshot_render_all_cameras` discovers camera names from the open scene and renders
255
+ them in one KeyShot headless session. `continueOnError` defaults to `true`, so a
256
+ failed camera is reported without blocking the remaining views. Safe duplicate
257
+ filenames receive `-2`, `-3`, and later suffixes. Existing files are preserved
258
+ unless `overwrite` is set to `true`. KeyShot's internal, non-activatable
259
+ `last_active` placeholder is reported and skipped.
260
+
236
261
  ### Material preset library
237
262
 
238
263
  `keyshot_apply_material_preset` reads a small JSON registry so you can reuse named
@@ -249,6 +274,30 @@ looks instead of remembering exact KeyShot material names. Default location:
249
274
  Each preset must have a `materialName` or a `materialPath`. Use
250
275
  `keyshot_list_material_presets` to see what is available.
251
276
 
277
+ ### Camera preset library
278
+
279
+ The built-in `presets/cameras.json` provides Front, Back, Left, Right, Top,
280
+ Bottom, and Isometric standard views. Use `keyshot_list_camera_presets` to list
281
+ them and `keyshot_apply_camera_preset` to create or update a named camera.
282
+
283
+ Set `KEYSHOT_CAMERA_PRESETS` to a user-managed JSON file for custom presets:
284
+
285
+ ```json
286
+ {
287
+ "Front": { "standardView": "front" },
288
+ "Hero": {
289
+ "position": [4.5, 3.5, 4.5],
290
+ "lookAt": [0, 0, 0],
291
+ "up": [0, 1, 0],
292
+ "description": "Custom absolute product camera"
293
+ }
294
+ }
295
+ ```
296
+
297
+ A preset file may use the object format shown above or an array whose entries
298
+ include a `name`. Each preset must contain either one supported `standardView`,
299
+ or both `position` and `lookAt`. Preset files are read-only to this MCP server.
300
+
252
301
  ## MCP Prompts and Resources
253
302
 
254
303
  - Prompt: `keyshot_product_render`
@@ -269,9 +318,9 @@ KeyShot's Python `lux` API changes across versions. This server keeps the MCP in
269
318
 
270
319
  ## Roadmap
271
320
 
272
- - [ ] Auto-discover and batch render all cameras in a scene
321
+ - [x] Auto-discover and batch render all cameras in a scene
273
322
  - [x] Material preset library
274
- - [ ] Camera preset templates
323
+ - [x] Camera preset templates
275
324
  - [x] Sequential render queue
276
325
  - [x] Safer output directory restrictions
277
326
  - [ ] More tested KeyShot versions
@@ -283,7 +332,7 @@ KeyShot's Python `lux` API changes across versions. This server keeps the MCP in
283
332
  ![KeyShot MCP generated cube demo](assets/demo/keyshot-mcp-demo.png)
284
333
 
285
334
  The demo uses only the generated geometry in `examples/demo`. To reproduce the
286
- status, import, inspect, camera, save, and PNG-render workflow locally:
335
+ status, import, inspect, two-camera creation, save, discovery, and PNG-render workflow locally:
287
336
 
288
337
  ```bash
289
338
  npm run smoke:keyshot
@@ -321,9 +370,11 @@ MIT
321
370
  - 检查 `.bip` 或 KeyShot 支持的场景文件。
322
371
  - 把场景渲染成图片。
323
372
  - 从同一个场景批量渲染多个指定相机。
373
+ - 自动发现并渲染场景中的全部相机。
324
374
  - 把模型导入场景。
325
375
  - 给对象替换材质。
326
376
  - 创建或更新相机。
377
+ - 应用可复用的标准或自定义相机预设。
327
378
  - 在当前 KeyShot 版本支持时设置环境。
328
379
  - 把场景保存为新文件。
329
380
 
@@ -360,7 +411,7 @@ MIT
360
411
  npm install -g keyshot-mcp
361
412
  ```
362
413
 
363
- 当前发布版本为 `0.4.2`。如果你只是想使用 MCP 服务,直接安装 npm 包最简单。
414
+ 当前发布版本为 `0.6.0`。如果你只是想使用 MCP 服务,直接安装 npm 包最简单。
364
415
  电脑仍需要安装 KeyShot Studio,并且已经配置好本地 KeyShot 授权。
365
416
 
366
417
  ### 从源码安装
@@ -383,7 +434,7 @@ npm run build
383
434
  **免安装,直接使用 npx**:
384
435
 
385
436
  ```json
386
- {"mcpServers":{"keyshot":{"command":"npx","args":["-y","keyshot-mcp@0.4.2"],"env":{"KEYSHOT_HEADLESS_EXE":"C:/KeyShot/bin/keyshot_headless.exe"}}}}
437
+ {"mcpServers":{"keyshot":{"command":"npx","args":["-y","keyshot-mcp@0.6.0"],"env":{"KEYSHOT_HEADLESS_EXE":"C:/KeyShot/bin/keyshot_headless.exe"}}}}
387
438
  ```
388
439
 
389
440
  **使用本地源码**(先运行 `npm install && npm run build`):
@@ -397,11 +448,11 @@ npm run build
397
448
  如果你使用 Codex 或其他编程 Agent,可以复制下面这段话,让 Agent 帮你安装和配置 MCP:
398
449
 
399
450
  ```text
400
- 请帮我安装 KeyShot MCP 0.4.2,并添加到我的 MCP 客户端。
451
+ 请帮我安装 KeyShot MCP 0.6.0,并添加到我的 MCP 客户端。
401
452
 
402
453
  请你:
403
454
  1. 查找我电脑上的 KeyShot headless 可执行文件路径。
404
- 2. 使用 npx -y keyshot-mcp@0.4.2 添加 keyshot MCP server。
455
+ 2. 使用 npx -y keyshot-mcp@0.6.0 添加 keyshot MCP server。
405
456
  3. 把 KEYSHOT_HEADLESS_EXE 设置为检测到的 keyshot_headless 路径。
406
457
  4. 用 keyshot_status 工具测试是否配置成功。
407
458
  5. 最后告诉我实际添加的配置,以及状态检查是否通过。
@@ -494,6 +545,16 @@ examples/codex.example.json
494
545
  把场景保存为新文件,并用这个相机渲染一张预览图。
495
546
  ```
496
547
 
548
+ ```text
549
+ 自动找出这个 KeyShot 场景里的全部相机,把所有视角渲染到 all-cameras 文件夹。
550
+ 某个相机失败时继续处理其他相机,最后告诉我每个视角的结果。
551
+ ```
552
+
553
+ ```text
554
+ 列出可用的相机预设,把 Isometric 等距预设应用为名为“目录等距视图”的相机,
555
+ 保存一份新场景,并用这个相机渲染预览图。
556
+ ```
557
+
497
558
  ## 环境变量
498
559
 
499
560
  - `KEYSHOT_HEADLESS_EXE`:`keyshot_headless` 或 `keyshot_headless.exe` 的路径。
@@ -502,6 +563,7 @@ examples/codex.example.json
502
563
  - `KEYSHOT_TIMEOUT_MS`:单次操作超时时间,单位毫秒,默认 `600000`。
503
564
  - `KEYSHOT_MATERIAL_PRESETS`:材质预设库 JSON 文件路径,默认 `presets/materials.json`。
504
565
  - `KEYSHOT_ALLOW_EXTERNAL_OUTPUTS`:是否允许写到默认输出目录之外,默认 `false`。
566
+ - `KEYSHOT_CAMERA_PRESETS`:相机预设 JSON 文件路径,默认 `presets/cameras.json`。
505
567
 
506
568
  默认情况下,所有输出图片和场景都必须写入 `KEYSHOT_OUTPUT_DIR`,相对路径会自动放入该目录。
507
569
  只有确实需要写到外部目录时才设置为 `true`;输入场景和模型路径不受此限制。
@@ -515,15 +577,25 @@ examples/codex.example.json
515
577
  - `keyshot_list_cameras`:列出场景中所有相机名称(批量渲染前很有用)。
516
578
  - `keyshot_render`:渲染单张图片。
517
579
  - `keyshot_batch_render`:批量渲染多个相机视角。
580
+ - `keyshot_render_all_cameras`:自动发现并渲染场景中的全部相机,默认单个视角失败后继续。
518
581
  - `keyshot_render_queue`:顺序执行多个渲染任务(默认遇错即停,设置 `continueOnError` 可继续)。
519
582
  - `keyshot_import_model`:导入模型。
520
583
  - `keyshot_apply_material`:替换材质。
521
584
  - `keyshot_list_material_presets`:列出材质预设库中的预设。
522
585
  - `keyshot_apply_material_preset`:把预设库中的某个命名材质应用到物体上。
523
586
  - `keyshot_set_camera`:设置相机。
587
+ - `keyshot_list_camera_presets`:列出标准和自定义相机预设。
588
+ - `keyshot_apply_camera_preset`:用预设创建或更新命名相机。
524
589
  - `keyshot_set_environment`:设置环境。
525
590
  - `keyshot_save_scene`:保存场景。
526
591
 
592
+ ### 自动渲染全部相机
593
+
594
+ `keyshot_render_all_cameras` 会在同一次 KeyShot 无界面运行中自动读取场景相机并逐个渲染。
595
+ `continueOnError` 默认是 `true`,因此单个相机失败不会阻止其他视角;同名安全文件会自动添加
596
+ `-2`、`-3` 等编号。除非设置 `overwrite: true`,否则不会覆盖已有图片。KeyShot 内部不能激活的
597
+ `last_active` 占位项会被列出并跳过。
598
+
527
599
  ### 材质预设库
528
600
 
529
601
  `keyshot_apply_material_preset` 会读取一个小的 JSON 注册表,让你用"好记的名字"复用材质,
@@ -539,6 +611,30 @@ examples/codex.example.json
539
611
 
540
612
  每个预设必须包含 `materialName` 或 `materialPath`。用 `keyshot_list_material_presets` 查看有哪些预设。
541
613
 
614
+ ### 相机预设库
615
+
616
+ 内置的 `presets/cameras.json` 包含 Front、Back、Left、Right、Top、Bottom 和
617
+ Isometric 七个 KeyShot 标准视角。使用 `keyshot_list_camera_presets` 查看预设,
618
+ 使用 `keyshot_apply_camera_preset` 创建或更新命名相机。
619
+
620
+ 把 `KEYSHOT_CAMERA_PRESETS` 指向用户自己的 JSON 文件即可添加自定义预设:
621
+
622
+ ```json
623
+ {
624
+ "正视图": { "standardView": "front" },
625
+ "产品主视角": {
626
+ "position": [4.5, 3.5, 4.5],
627
+ "lookAt": [0, 0, 0],
628
+ "up": [0, 1, 0],
629
+ "description": "自定义绝对坐标相机"
630
+ }
631
+ }
632
+ ```
633
+
634
+ 预设文件既可以使用上面的对象格式,也可以使用每项包含 `name` 的数组格式。
635
+ 每个预设只能选择一种形式:填写一个受支持的 `standardView`,或者同时填写
636
+ `position` 与 `lookAt`。MCP 只读取预设文件,不会自动修改它。
637
+
542
638
  ## MCP 提示词和资源
543
639
 
544
640
  - 提示词:`keyshot_product_render`
@@ -559,9 +655,9 @@ KeyShot 的 Python `lux` API 会随版本变化。这个 MCP 会尽量保持对
559
655
 
560
656
  ## 路线图
561
657
 
562
- - [ ] 自动发现并批量渲染场景中的所有相机
658
+ - [x] 自动发现并批量渲染场景中的所有相机
563
659
  - [x] 材质预设库
564
- - [ ] 相机预设模板
660
+ - [x] 相机预设模板
565
661
  - [x] 顺序渲染队列
566
662
  - [x] 更安全的输出目录限制
567
663
  - [ ] 测试更多 KeyShot 版本
@@ -573,7 +669,7 @@ KeyShot 的 Python `lux` API 会随版本变化。这个 MCP 会尽量保持对
573
669
  ![KeyShot MCP 自动生成的立方体 Demo](assets/demo/keyshot-mcp-demo.png)
574
670
 
575
671
  Demo 只使用 `examples/demo` 中项目自己生成的几何体,不包含客户模型或第三方素材。
576
- 设置好 `KEYSHOT_HEADLESS_EXE` 后可复现状态检查、导入、检查场景、创建相机、保存和真实 PNG 渲染:
672
+ 设置好 `KEYSHOT_HEADLESS_EXE` 后可复现状态检查、导入、检查场景、创建两个相机、自动发现、保存和真实 PNG 渲染:
577
673
 
578
674
  ```bash
579
675
  npm run smoke:keyshot
@@ -0,0 +1,86 @@
1
+ import fs from "node:fs/promises";
2
+ export const STANDARD_CAMERA_VIEWS = [
3
+ "front",
4
+ "back",
5
+ "left",
6
+ "right",
7
+ "top",
8
+ "bottom",
9
+ "isometric",
10
+ ];
11
+ export async function loadCameraPresets(config) {
12
+ let raw;
13
+ try {
14
+ raw = await fs.readFile(config.cameraPresetsPath, "utf8");
15
+ }
16
+ catch {
17
+ return [];
18
+ }
19
+ let parsed;
20
+ try {
21
+ parsed = JSON.parse(raw);
22
+ }
23
+ catch {
24
+ throw new Error(`Camera presets file is not valid JSON: ${config.cameraPresetsPath}`);
25
+ }
26
+ return normalizeCameraPresets(parsed);
27
+ }
28
+ export function normalizeCameraPresets(parsed) {
29
+ const entries = [];
30
+ if (Array.isArray(parsed)) {
31
+ for (const item of parsed) {
32
+ if (!item || typeof item !== "object")
33
+ continue;
34
+ const record = item;
35
+ if (typeof record.name !== "string" || !record.name)
36
+ continue;
37
+ const preset = toCameraPreset(record.name, record);
38
+ if (preset)
39
+ entries.push(preset);
40
+ }
41
+ }
42
+ else if (parsed && typeof parsed === "object") {
43
+ for (const [name, value] of Object.entries(parsed)) {
44
+ if (!value || typeof value !== "object")
45
+ continue;
46
+ const preset = toCameraPreset(name, value);
47
+ if (preset)
48
+ entries.push(preset);
49
+ }
50
+ }
51
+ return entries;
52
+ }
53
+ export function findCameraPreset(presets, name) {
54
+ const lower = name.toLowerCase();
55
+ return (presets.find((preset) => preset.name === name) ??
56
+ presets.find((preset) => preset.name.toLowerCase() === lower));
57
+ }
58
+ function toCameraPreset(name, record) {
59
+ const description = typeof record.description === "string" ? record.description : undefined;
60
+ const standardView = normalizeStandardView(record.standardView);
61
+ const position = toVector(record.position);
62
+ const lookAt = toVector(record.lookAt);
63
+ const up = toVector(record.up);
64
+ const hasStandard = standardView !== undefined;
65
+ const hasAbsolute = position !== undefined || lookAt !== undefined || up !== undefined;
66
+ if (hasStandard && !hasAbsolute) {
67
+ return { name, type: "standard", standardView, description };
68
+ }
69
+ if (!hasStandard && position && lookAt) {
70
+ return { name, type: "absolute", position, lookAt, up, description };
71
+ }
72
+ return undefined;
73
+ }
74
+ function normalizeStandardView(value) {
75
+ if (typeof value !== "string")
76
+ return undefined;
77
+ const normalized = value.toLowerCase();
78
+ return STANDARD_CAMERA_VIEWS.find((view) => view === normalized);
79
+ }
80
+ function toVector(value) {
81
+ if (!Array.isArray(value) || value.length !== 3)
82
+ return undefined;
83
+ if (!value.every((item) => typeof item === "number" && Number.isFinite(item)))
84
+ return undefined;
85
+ return [value[0], value[1], value[2]];
86
+ }
package/dist/config.js CHANGED
@@ -15,6 +15,7 @@ export function getConfig() {
15
15
  tmpDir: path.join(projectRoot, "work", "tmp"),
16
16
  bridgeScriptPath: path.join(projectRoot, "scripts", "keyshot_bridge.py"),
17
17
  materialPresetsPath: path.resolve(process.env.KEYSHOT_MATERIAL_PRESETS ?? path.join(projectRoot, "presets", "materials.json")),
18
+ cameraPresetsPath: path.resolve(process.env.KEYSHOT_CAMERA_PRESETS ?? path.join(projectRoot, "presets", "cameras.json")),
18
19
  };
19
20
  }
20
21
  function parseBoolean(value) {
package/dist/index.js CHANGED
@@ -6,8 +6,9 @@ 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 { loadCameraPresets, findCameraPreset } from "./camera-presets.js";
9
10
  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";
11
+ import { applyMaterialSchema, applyMaterialInputSchema, applyMaterialPresetInputSchema, applyMaterialPresetSchema, applyCameraPresetSchema, batchRenderSchema, batchRenderInputSchema, importModelSchema, listCamerasSchema, listMaterialPresetsSchema, listCameraPresetsSchema, renderQueueSchema, renderQueueInputSchema, renderAllCamerasSchema, renderAllCamerasInputSchema, renderSchema, renderInputSchema, saveSceneSchema, scenePathSchema, setCameraSchema, setEnvironmentSchema, } from "./schemas.js";
11
12
  const config = getConfig();
12
13
  const server = new McpServer({
13
14
  name: "keyshot-mcp",
@@ -72,6 +73,10 @@ server.tool("keyshot_batch_render", "Render multiple named cameras from one KeyS
72
73
  const parsed = batchRenderInputSchema.parse(args);
73
74
  return toolResponse(await runKeyShotSerialized(config, { operation: "batch_render", ...parsed }));
74
75
  });
76
+ 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) => {
77
+ const parsed = renderAllCamerasInputSchema.parse(args);
78
+ return toolResponse(await runKeyShotSerialized(config, { operation: "render_all_cameras", ...parsed }));
79
+ });
75
80
  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, {
76
81
  operation: "import_model",
77
82
  ...args,
@@ -121,6 +126,58 @@ server.tool("keyshot_apply_material_preset", "Apply a named material preset (fro
121
126
  }));
122
127
  });
123
128
  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 })));
129
+ server.tool("keyshot_list_camera_presets", "List standard and custom camera presets from presets/cameras.json or KEYSHOT_CAMERA_PRESETS.", listCameraPresetsSchema.shape, async () => {
130
+ try {
131
+ const presets = await loadCameraPresets(config);
132
+ return toolResponse({
133
+ ok: true,
134
+ data: { presets, count: presets.length, source: config.cameraPresetsPath },
135
+ outputFiles: [],
136
+ warnings: presets.length === 0 ? ["No valid camera presets found."] : [],
137
+ keyshotStdoutTail: "",
138
+ error: null,
139
+ });
140
+ }
141
+ catch (error) {
142
+ return toolResponse(localFailure(errorMessage(error)));
143
+ }
144
+ });
145
+ server.tool("keyshot_apply_camera_preset", "Create or update a named camera from a standard or custom camera preset and save the scene.", applyCameraPresetSchema.shape, async (args) => {
146
+ let presets;
147
+ try {
148
+ presets = await loadCameraPresets(config);
149
+ }
150
+ catch (error) {
151
+ return toolResponse(localFailure(errorMessage(error)));
152
+ }
153
+ const preset = findCameraPreset(presets, args.presetName);
154
+ if (!preset) {
155
+ const available = presets.map((entry) => entry.name).join(", ") || "(none)";
156
+ return toolResponse(localFailure(`Camera preset not found: "${args.presetName}". Available: ${available}`));
157
+ }
158
+ const request = preset.type === "standard"
159
+ ? {
160
+ operation: "set_standard_camera",
161
+ scenePath: args.scenePath,
162
+ standardView: preset.standardView,
163
+ cameraName: args.cameraName ?? preset.name,
164
+ outputScenePath: args.outputScenePath,
165
+ }
166
+ : {
167
+ operation: "set_camera",
168
+ scenePath: args.scenePath,
169
+ cameraName: args.cameraName ?? preset.name,
170
+ position: preset.position,
171
+ lookAt: preset.lookAt,
172
+ up: preset.up,
173
+ outputScenePath: args.outputScenePath,
174
+ };
175
+ const result = await runKeyShotSerialized(config, request);
176
+ if (result.data && typeof result.data === "object") {
177
+ result.data = { presetName: preset.name, presetType: preset.type, ...result.data };
178
+ }
179
+ return toolResponse(result);
180
+ });
124
181
  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 })));
125
182
  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 })));
126
183
  const transport = new StdioServerTransport();
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,
@@ -64,6 +79,13 @@ export const setCameraSchema = z.object({
64
79
  distance: z.number().positive().optional(),
65
80
  outputScenePath: z.string().min(1),
66
81
  });
82
+ export const listCameraPresetsSchema = z.object({});
83
+ export const applyCameraPresetSchema = z.object({
84
+ scenePath: z.string().min(1),
85
+ presetName: z.string().min(1),
86
+ cameraName: z.string().min(1).optional(),
87
+ outputScenePath: z.string().min(1),
88
+ });
67
89
  export const setEnvironmentSchema = z.object({
68
90
  scenePath: z.string().min(1),
69
91
  environmentName: z.string().min(1).optional(),
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.4.2";
1
+ export const VERSION = "0.6.0";
@@ -4,6 +4,7 @@
4
4
  repository's MIT license. It contains no customer model or third-party asset.
5
5
 
6
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 an MCP
8
- camera, and renders `outputs/demo/keyshot-mcp-demo.png`. Generated `.bip` files
9
- remain under `outputs/` and are ignored by Git.
7
+ checks KeyShot, imports the cube, saves and inspects the scene, applies the
8
+ built-in Front and Isometric camera presets, 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keyshot-mcp",
3
- "version": "0.4.2",
3
+ "version": "0.6.0",
4
4
  "mcpName": "io.github.truman-t3/keyshot-mcp",
5
5
  "description": "Local and portable MCP server for KeyShot headless scripting.",
6
6
  "type": "module",
@@ -0,0 +1,30 @@
1
+ {
2
+ "Front": {
3
+ "standardView": "front",
4
+ "description": "Front standard view / 正视图"
5
+ },
6
+ "Back": {
7
+ "standardView": "back",
8
+ "description": "Back standard view / 后视图"
9
+ },
10
+ "Left": {
11
+ "standardView": "left",
12
+ "description": "Left standard view / 左视图"
13
+ },
14
+ "Right": {
15
+ "standardView": "right",
16
+ "description": "Right standard view / 右视图"
17
+ },
18
+ "Top": {
19
+ "standardView": "top",
20
+ "description": "Top standard view / 顶视图"
21
+ },
22
+ "Bottom": {
23
+ "standardView": "bottom",
24
+ "description": "Bottom standard view / 底视图"
25
+ },
26
+ "Isometric": {
27
+ "standardView": "isometric",
28
+ "description": "Isometric standard view / 等距视图"
29
+ }
30
+ }
@@ -1,5 +1,8 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs/promises";
1
3
  import path from "node:path";
2
4
  import { fileURLToPath } from "node:url";
5
+ import { findCameraPreset, loadCameraPresets } from "../dist/camera-presets.js";
3
6
  import { getConfig } from "../dist/config.js";
4
7
  import { runKeyShotSerialized } from "../dist/runner.js";
5
8
 
@@ -18,8 +21,23 @@ async function run(label, request) {
18
21
  return result;
19
22
  }
20
23
 
21
- const status = await run("1/5 KeyShot status", { operation: "status" });
22
- const imported = await run("2/5 Import and save generated OBJ", {
24
+ async function applyStandardPreset(label, scenePath, presetName, outputScenePath) {
25
+ const presets = await loadCameraPresets(config);
26
+ const preset = findCameraPreset(presets, presetName);
27
+ if (!preset || preset.type !== "standard") {
28
+ throw new Error(`Standard camera preset not found: ${presetName}`);
29
+ }
30
+ return run(label, {
31
+ operation: "set_standard_camera",
32
+ scenePath,
33
+ cameraName: preset.name,
34
+ standardView: preset.standardView,
35
+ outputScenePath,
36
+ });
37
+ }
38
+
39
+ const status = await run("1/7 KeyShot status", { operation: "status" });
40
+ const imported = await run("2/7 Import and save generated OBJ", {
23
41
  operation: "import_model",
24
42
  modelPath,
25
43
  outputScenePath: "demo/keyshot-mcp-demo-import.bip",
@@ -27,7 +45,7 @@ const imported = await run("2/5 Import and save generated OBJ", {
27
45
  const importedScene = imported.outputFiles[0];
28
46
  if (!importedScene) throw new Error("Import did not return a saved scene path.");
29
47
 
30
- const inspected = await run("3/5 Inspect imported scene", {
48
+ const inspected = await run("3/7 Inspect imported scene", {
31
49
  operation: "inspect_scene",
32
50
  scenePath: importedScene,
33
51
  });
@@ -36,32 +54,64 @@ if (!Array.isArray(objects) || objects.length === 0) {
36
54
  throw new Error("Imported scene did not contain any inspectable objects.");
37
55
  }
38
56
 
39
- const cameraSceneResult = await run("4/5 Create camera and save scene", {
40
- operation: "set_camera",
41
- scenePath: importedScene,
42
- cameraName: "MCP Demo",
43
- position: [4.5, 3.5, 4.5],
44
- lookAt: [0, 0, 0],
45
- up: [0, 1, 0],
46
- outputScenePath: "demo/keyshot-mcp-demo-camera.bip",
47
- });
48
- const cameraScene = cameraSceneResult.outputFiles[0];
49
- if (!cameraScene) throw new Error("Camera operation did not return a saved scene path.");
57
+ const firstCameraResult = await applyStandardPreset(
58
+ "4/7 Apply Front camera preset",
59
+ importedScene,
60
+ "Front",
61
+ "demo/keyshot-mcp-demo-preset-front.bip",
62
+ );
63
+ const firstCameraScene = firstCameraResult.outputFiles[0];
64
+ if (!firstCameraScene) throw new Error("Front preset did not return a saved scene path.");
65
+
66
+ const secondCameraResult = await applyStandardPreset(
67
+ "5/7 Apply Isometric camera preset",
68
+ firstCameraScene,
69
+ "Isometric",
70
+ "demo/keyshot-mcp-demo-presets.bip",
71
+ );
72
+ const cameraScene = secondCameraResult.outputFiles[0];
73
+ if (!cameraScene) throw new Error("Isometric preset did not return a saved scene path.");
50
74
 
51
- const rendered = await run("5/5 Render real PNG", {
52
- operation: "render",
75
+ const rendered = await run("6/7 Discover and render every camera", {
76
+ operation: "render_all_cameras",
53
77
  scenePath: cameraScene,
54
- camera: "MCP Demo",
55
- outputPath: "demo/keyshot-mcp-demo.png",
78
+ outputDir: "demo/all-cameras",
56
79
  width: 640,
57
80
  height: 480,
58
81
  maxTimeSeconds: 8,
59
82
  format: "png",
83
+ overwrite: true,
84
+ continueOnError: true,
60
85
  });
61
86
 
87
+ const renderData = rendered.data;
88
+ if (!renderData || renderData.failed !== 0 || renderData.succeeded < 2) {
89
+ throw new Error("All-camera render did not produce at least two successful camera views.");
90
+ }
91
+
92
+ const namedResults = renderData.results.filter(
93
+ (entry) => entry.camera === "Front" || entry.camera === "Isometric",
94
+ );
95
+ if (namedResults.length !== 2 || namedResults.some((entry) => !entry.ok)) {
96
+ throw new Error("The Front and Isometric preset cameras were not both rendered successfully.");
97
+ }
98
+ const hashes = await Promise.all(namedResults.map(async (entry) =>
99
+ crypto.createHash("sha256").update(await fs.readFile(entry.outputPath)).digest("hex")
100
+ ));
101
+ if (hashes[0] === hashes[1]) {
102
+ throw new Error("The two demo camera renders are identical; expected different viewpoints.");
103
+ }
104
+ const minimumSizes = await Promise.all(namedResults.map(async (entry) => (await fs.stat(entry.outputPath)).size));
105
+ if (minimumSizes.some((size) => size < 10000)) {
106
+ throw new Error("A demo camera render is unexpectedly small and may be blank.");
107
+ }
108
+ process.stdout.write("7/7 Verify two different camera images... ok\n");
109
+
62
110
  console.log(JSON.stringify({
63
111
  keyshotVersion: status.data?.version ?? null,
64
112
  objectCount: objects.length,
65
113
  scenePath: cameraScene,
66
- imagePath: rendered.outputFiles[0] ?? null,
114
+ cameraCount: renderData.total,
115
+ renderedImages: rendered.outputFiles,
116
+ verifiedPresets: namedResults.map((entry) => entry.camera),
67
117
  }, null, 2));
@@ -37,12 +37,16 @@ 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":
43
45
  data = apply_material(payload, output_files, warnings)
44
46
  elif operation == "set_camera":
45
47
  data = set_camera(payload, output_files, warnings)
48
+ elif operation == "set_standard_camera":
49
+ data = set_standard_camera(payload, output_files, warnings)
46
50
  elif operation == "set_environment":
47
51
  data = set_environment(payload, output_files, warnings)
48
52
  elif operation == "save_scene":
@@ -50,7 +54,10 @@ def main():
50
54
  else:
51
55
  raise RuntimeError("Unsupported operation: %s" % operation)
52
56
 
53
- write_result(result_path, True, data, output_files, warnings, None)
57
+ operation_error = None
58
+ if operation == "render_all_cameras" and data.get("failed", 0) > 0:
59
+ operation_error = "%s of %s camera render(s) failed." % (data["failed"], data["total"])
60
+ write_result(result_path, operation_error is None, data, output_files, warnings, operation_error)
54
61
  except Exception as exc:
55
62
  warnings.append(traceback.format_exc())
56
63
  write_result(result_path, False, None, output_files, warnings, str(exc))
@@ -99,6 +106,11 @@ def inspect_scene():
99
106
 
100
107
 
101
108
  def list_cameras():
109
+ names = camera_names()
110
+ return {"cameras": names, "count": len(names)}
111
+
112
+
113
+ def camera_names():
102
114
  raw = safe_list_call("getCameras")
103
115
  names = []
104
116
  for camera in raw:
@@ -113,8 +125,8 @@ def list_cameras():
113
125
  )
114
126
  if name is None:
115
127
  name = repr(camera)
116
- names.append(name)
117
- return {"cameras": names, "count": len(names)}
128
+ names.append(str(name))
129
+ return names
118
130
 
119
131
 
120
132
  def render(payload, output_files, warnings):
@@ -214,6 +226,100 @@ def batch_render(payload, output_files, warnings):
214
226
  }
215
227
 
216
228
 
229
+ def render_all_cameras(payload, output_files, warnings):
230
+ scene_path = payload.get("scenePath")
231
+ output_dir = payload.get("outputDir")
232
+ discovered_cameras = camera_names()
233
+ excluded_cameras = [name for name in discovered_cameras if name.lower() == "last_active"]
234
+ cameras = [name for name in discovered_cameras if name.lower() != "last_active"]
235
+ width = payload.get("width") or 1920
236
+ height = payload.get("height") or 1080
237
+ image_format = payload.get("format") or "png"
238
+ overwrite = bool(payload.get("overwrite", False))
239
+ continue_on_error = payload.get("continueOnError", True) is not False
240
+
241
+ if not scene_path:
242
+ raise RuntimeError("scenePath is required")
243
+ if not output_dir:
244
+ raise RuntimeError("outputDir is required")
245
+ if not cameras:
246
+ raise RuntimeError("No cameras were found in the scene.")
247
+
248
+ if excluded_cameras:
249
+ warnings.append("Skipped KeyShot internal camera placeholder(s): %s" % ", ".join(excluded_cameras))
250
+
251
+ os.makedirs(output_dir, exist_ok=True)
252
+ results = []
253
+ used_stems = set()
254
+ stopped = False
255
+
256
+ for index, camera in enumerate(cameras):
257
+ output_path = unique_camera_output_path(output_dir, camera, image_format, used_stems)
258
+ if stopped:
259
+ results.append(camera_render_result(index, camera, output_path, False, None, True))
260
+ continue
261
+
262
+ try:
263
+ if os.path.exists(output_path) and not overwrite:
264
+ raise RuntimeError("Output already exists and overwrite is false: %s" % output_path)
265
+
266
+ render_payload = dict(payload)
267
+ render_payload["camera"] = camera
268
+ render_payload["outputPath"] = output_path
269
+ render_payload["width"] = width
270
+ render_payload["height"] = height
271
+ render_payload["format"] = image_format
272
+ rendered = render(render_payload, output_files, warnings)
273
+ results.append(camera_render_result(index, camera, rendered.get("rendered"), True, None, False))
274
+ except Exception as exc:
275
+ error = str(exc)
276
+ warnings.append("Camera %s (%s) failed: %s" % (index, camera, error))
277
+ results.append(camera_render_result(index, camera, output_path, False, error, False))
278
+ if not continue_on_error:
279
+ stopped = True
280
+
281
+ succeeded = len([item for item in results if item["ok"]])
282
+ failed = len([item for item in results if not item["ok"] and not item["skipped"]])
283
+ skipped = len([item for item in results if item["skipped"]])
284
+ return {
285
+ "scenePath": scene_path,
286
+ "outputDir": output_dir,
287
+ "cameras": cameras,
288
+ "excludedCameras": excluded_cameras,
289
+ "total": len(cameras),
290
+ "succeeded": succeeded,
291
+ "failed": failed,
292
+ "skipped": skipped,
293
+ "continueOnError": continue_on_error,
294
+ "results": results,
295
+ "width": width,
296
+ "height": height,
297
+ "format": image_format,
298
+ }
299
+
300
+
301
+ def camera_render_result(index, camera, output_path, ok, error, skipped):
302
+ return {
303
+ "index": index,
304
+ "camera": camera,
305
+ "outputPath": output_path,
306
+ "ok": ok,
307
+ "error": error,
308
+ "skipped": skipped,
309
+ }
310
+
311
+
312
+ def unique_camera_output_path(output_dir, camera, image_format, used_stems):
313
+ base = safe_filename(camera)
314
+ stem = base
315
+ suffix = 2
316
+ while stem.lower() in used_stems:
317
+ stem = "%s-%s" % (base, suffix)
318
+ suffix += 1
319
+ used_stems.add(stem.lower())
320
+ return os.path.join(output_dir, "%s.%s" % (stem, image_format))
321
+
322
+
217
323
  def import_model(payload, output_files, warnings):
218
324
  model_path = payload.get("modelPath")
219
325
  output_scene_path = payload.get("outputScenePath")
@@ -298,6 +404,7 @@ def set_camera(payload, output_files, warnings):
298
404
 
299
405
  if position is None or look_at is None:
300
406
  raise RuntimeError("position and lookAt are required")
407
+ direction = tuple(look_at[index] - position[index] for index in range(3))
301
408
 
302
409
  camera = first_camera_object(
303
410
  lambda: lux.getCamera(camera_name),
@@ -305,47 +412,53 @@ def set_camera(payload, output_files, warnings):
305
412
  lambda: lux.createCamera(camera_name),
306
413
  )
307
414
 
308
- if camera is None and hasattr(lux, "saveCamera"):
309
- try:
310
- # Current KeyShot versions expose cameras as names rather than camera
311
- # objects. saveCamera creates the named camera and setCamera activates it.
312
- call_variants(
313
- "create named camera",
314
- lambda: lux.saveCamera(camera_name),
315
- lambda: lux.saveCamera(),
316
- )
317
- if hasattr(lux, "setCamera"):
318
- call_variants("activate camera", lambda: lux.setCamera(camera_name))
319
- except RuntimeError:
320
- pass
321
-
322
415
  if camera is not None:
323
416
  # Object-level API is available: drive the camera object directly.
324
- call_variants("set camera position", lambda: camera.setPosition(tuple(position)))
325
417
  call_variants("set camera look-at", lambda: camera.setLookAt(tuple(look_at)))
418
+ call_variants("set camera position", lambda: camera.setPosition(tuple(position)))
419
+ if hasattr(camera, "setDirection"):
420
+ call_variants("set camera direction", lambda: camera.setDirection(direction))
326
421
  call_variants("set camera up", lambda: camera.setUp(tuple(up)))
327
422
  else:
328
423
  # No camera object could be obtained: fall back to the lux-level setters,
329
- # which address the camera by name. This path never dereferences a None
330
- # camera object, so it cannot crash when camera creation is unsupported.
331
- call_variants(
332
- "set camera position",
333
- lambda: lux.setCameraPosition(pos=tuple(position)),
334
- lambda: lux.setCameraPosition(tuple(position)),
335
- lambda: lux.setCameraPosition(camera_name, tuple(position)),
336
- )
424
+ # which operate on the active camera. Existing named cameras are activated
425
+ # first. Missing cameras are saved under their new name after the transform
426
+ # is set; saveCamera is a snapshot operation, not a camera constructor.
427
+ if camera_name in camera_names() and hasattr(lux, "setCamera"):
428
+ call_variants("activate camera", lambda: lux.setCamera(camera_name))
337
429
  call_variants(
338
430
  "set camera look-at",
339
431
  lambda: lux.setCameraLookAt(pt=tuple(look_at)),
340
432
  lambda: lux.setCameraLookAt(0, tuple(look_at)),
341
433
  lambda: lux.setCameraLookAt(camera_name, tuple(look_at)),
342
434
  )
435
+ call_variants(
436
+ "set camera position",
437
+ lambda: lux.setCameraPosition(pos=tuple(position)),
438
+ lambda: lux.setCameraPosition(tuple(position)),
439
+ lambda: lux.setCameraPosition(camera_name, tuple(position)),
440
+ )
441
+ if hasattr(lux, "setCameraDirection"):
442
+ call_variants(
443
+ "set camera direction",
444
+ lambda: lux.setCameraDirection(dir=direction),
445
+ lambda: lux.setCameraDirection(direction),
446
+ )
343
447
  call_variants(
344
448
  "set camera up",
345
449
  lambda: lux.setCameraUp(up=tuple(up)),
346
450
  lambda: lux.setCameraUp(tuple(up)),
347
451
  lambda: lux.setCameraUp(camera_name, tuple(up)),
348
452
  )
453
+ if hasattr(lux, "saveCamera"):
454
+ try:
455
+ call_variants(
456
+ "save named camera",
457
+ lambda: lux.saveCamera(),
458
+ lambda: lux.saveCamera(camera_name),
459
+ )
460
+ except RuntimeError:
461
+ warnings.append("The active camera was updated, but this KeyShot version could not save the named view.")
349
462
 
350
463
  save_to(output_scene_path)
351
464
  output_files.append(output_scene_path)
@@ -358,6 +471,54 @@ def set_camera(payload, output_files, warnings):
358
471
  }
359
472
 
360
473
 
474
+ def set_standard_camera(payload, output_files, warnings):
475
+ output_scene_path = payload.get("outputScenePath")
476
+ camera_name = payload.get("cameraName") or "MCP Camera"
477
+ standard_view = str(payload.get("standardView") or "").lower()
478
+ view_constants = {
479
+ "front": "VIEW_FRONT",
480
+ "back": "VIEW_BACK",
481
+ "left": "VIEW_LEFT",
482
+ "right": "VIEW_RIGHT",
483
+ "top": "VIEW_TOP",
484
+ "bottom": "VIEW_BOTTOM",
485
+ "isometric": "VIEW_ISOMETRIC",
486
+ }
487
+
488
+ constant_name = view_constants.get(standard_view)
489
+ if constant_name is None:
490
+ raise RuntimeError("Unsupported standard camera view: %s" % standard_view)
491
+ if not hasattr(lux, "setStandardView"):
492
+ raise RuntimeError("KeyShot lux.setStandardView is not available.")
493
+ if not hasattr(lux, constant_name):
494
+ raise RuntimeError("KeyShot camera constant is not available: %s" % constant_name)
495
+
496
+ if camera_name in camera_names():
497
+ if not hasattr(lux, "setCamera"):
498
+ raise RuntimeError("KeyShot cannot activate the existing camera: %s" % camera_name)
499
+ call_non_false_variants("activate camera", lambda: lux.setCamera(camera_name))
500
+ else:
501
+ call_non_false_variants(
502
+ "create camera",
503
+ lambda: lux.newCamera(camera_name),
504
+ lambda: lux.createCamera(camera_name),
505
+ )
506
+
507
+ call_variants("set standard camera view", lambda: lux.setStandardView(getattr(lux, constant_name)))
508
+ call_variants(
509
+ "save standard camera",
510
+ lambda: lux.saveCamera(),
511
+ lambda: lux.saveCamera(camera_name),
512
+ )
513
+ save_to(output_scene_path)
514
+ output_files.append(output_scene_path)
515
+ return {
516
+ "cameraName": camera_name,
517
+ "standardView": standard_view,
518
+ "savedScene": output_scene_path,
519
+ }
520
+
521
+
361
522
  def set_environment(payload, output_files, warnings):
362
523
  output_scene_path = payload.get("outputScenePath")
363
524
  environment_name = payload.get("environmentName")
@@ -491,6 +652,19 @@ def call_variants(label, *callbacks):
491
652
  raise RuntimeError("%s is unsupported or failed: %s" % (label, " | ".join(errors)))
492
653
 
493
654
 
655
+ def call_non_false_variants(label, *callbacks):
656
+ errors = []
657
+ for callback in callbacks:
658
+ try:
659
+ value = callback()
660
+ if value is not False:
661
+ return value
662
+ errors.append("returned false")
663
+ except Exception as exc:
664
+ errors.append(str(exc))
665
+ raise RuntimeError("%s is unsupported or failed: %s" % (label, " | ".join(errors)))
666
+
667
+
494
668
  def first_success(*callbacks, default=None):
495
669
  for callback in callbacks:
496
670
  try:
package/server.json CHANGED
@@ -9,12 +9,12 @@
9
9
  "url": "https://github.com/truman-t3/keyshot-mcp",
10
10
  "source": "github"
11
11
  },
12
- "version": "0.4.2",
12
+ "version": "0.6.0",
13
13
  "packages": [
14
14
  {
15
15
  "registryType": "npm",
16
16
  "identifier": "keyshot-mcp",
17
- "version": "0.4.2",
17
+ "version": "0.6.0",
18
18
  "transport": {
19
19
  "type": "stdio"
20
20
  },
@@ -24,6 +24,12 @@
24
24
  "description": "Absolute path to KeyShot headless, or a command available on PATH.",
25
25
  "isRequired": true,
26
26
  "isSecret": false
27
+ },
28
+ {
29
+ "name": "KEYSHOT_CAMERA_PRESETS",
30
+ "description": "Optional path to a user-managed camera preset JSON file.",
31
+ "isRequired": false,
32
+ "isSecret": false
27
33
  }
28
34
  ]
29
35
  }