keyshot-mcp 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,6 +28,7 @@ This project does not include KeyShot, does not bypass licensing, and does not s
28
28
  - Inspect a `.bip` or supported KeyShot scene file.
29
29
  - Render a scene to an image.
30
30
  - Batch render multiple named cameras from one scene.
31
+ - Automatically discover and render every camera in one scene.
31
32
  - Import a model into a scene.
32
33
  - Apply a material to an object.
33
34
  - Create or update a camera.
@@ -67,7 +68,7 @@ This project does not include KeyShot, does not bypass licensing, and does not s
67
68
  npm install -g keyshot-mcp
68
69
  ```
69
70
 
70
- The current release line is `0.4.2`. The published package is the easiest option
71
+ The current release line is `0.5.0`. The published package is the easiest option
71
72
  when you only want to use the MCP server. You still need KeyShot Studio and a
72
73
  local KeyShot license.
73
74
 
@@ -91,7 +92,7 @@ Use exactly one of these approaches:
91
92
  **No installation, run with npx**:
92
93
 
93
94
  ```json
94
- {"mcpServers":{"keyshot":{"command":"npx","args":["-y","keyshot-mcp@0.4.2"],"env":{"KEYSHOT_HEADLESS_EXE":"/absolute/path/to/keyshot_headless"}}}}
95
+ {"mcpServers":{"keyshot":{"command":"npx","args":["-y","keyshot-mcp@0.5.0"],"env":{"KEYSHOT_HEADLESS_EXE":"/absolute/path/to/keyshot_headless"}}}}
95
96
  ```
96
97
 
97
98
  **Run from a cloned source directory** (after `npm install && npm run build`):
@@ -105,11 +106,11 @@ Use exactly one of these approaches:
105
106
  If you use Codex or another coding agent, you can copy this prompt and let the agent install the MCP server for you:
106
107
 
107
108
  ```text
108
- Install KeyShot MCP 0.4.2 from npm and configure it in my MCP client.
109
+ Install KeyShot MCP 0.5.0 from npm and configure it in my MCP client.
109
110
 
110
111
  Please:
111
112
  1. Find my KeyShot headless executable path.
112
- 2. Add a keyshot MCP server using npx -y keyshot-mcp@0.4.2.
113
+ 2. Add a keyshot MCP server using npx -y keyshot-mcp@0.5.0.
113
114
  3. Set KEYSHOT_HEADLESS_EXE to the detected keyshot_headless path.
114
115
  4. Test the setup by running the keyshot_status tool.
115
116
  5. Tell me the exact config that was added and whether the status check passed.
@@ -202,6 +203,11 @@ List the cameras in this KeyShot scene, create or update a camera named "Hero",
202
203
  save the scene to a new file, and render one preview from that camera.
203
204
  ```
204
205
 
206
+ ```text
207
+ Find every camera in this KeyShot scene and render all views to the all-cameras
208
+ output folder. Continue if one camera fails and report the result for each view.
209
+ ```
210
+
205
211
  ## Environment Variables
206
212
 
207
213
  - `KEYSHOT_HEADLESS_EXE`: path to `keyshot_headless` or `keyshot_headless.exe`.
@@ -224,6 +230,7 @@ or the other, not both.
224
230
  - `keyshot_list_cameras`: list available camera names in a scene (handy before batch rendering)
225
231
  - `keyshot_render`: render a single image
226
232
  - `keyshot_batch_render`
233
+ - `keyshot_render_all_cameras`: discover and render every camera in a scene; continues after individual failures by default
227
234
  - `keyshot_render_queue`: run several render jobs sequentially (stops at first failure unless `continueOnError`)
228
235
  - `keyshot_import_model`
229
236
  - `keyshot_apply_material`
@@ -233,6 +240,15 @@ or the other, not both.
233
240
  - `keyshot_set_environment`
234
241
  - `keyshot_save_scene`
235
242
 
243
+ ### Render every camera automatically
244
+
245
+ `keyshot_render_all_cameras` discovers camera names from the open scene and renders
246
+ them in one KeyShot headless session. `continueOnError` defaults to `true`, so a
247
+ failed camera is reported without blocking the remaining views. Safe duplicate
248
+ filenames receive `-2`, `-3`, and later suffixes. Existing files are preserved
249
+ unless `overwrite` is set to `true`. KeyShot's internal, non-activatable
250
+ `last_active` placeholder is reported and skipped.
251
+
236
252
  ### Material preset library
237
253
 
238
254
  `keyshot_apply_material_preset` reads a small JSON registry so you can reuse named
@@ -269,7 +285,7 @@ KeyShot's Python `lux` API changes across versions. This server keeps the MCP in
269
285
 
270
286
  ## Roadmap
271
287
 
272
- - [ ] Auto-discover and batch render all cameras in a scene
288
+ - [x] Auto-discover and batch render all cameras in a scene
273
289
  - [x] Material preset library
274
290
  - [ ] Camera preset templates
275
291
  - [x] Sequential render queue
@@ -283,7 +299,7 @@ KeyShot's Python `lux` API changes across versions. This server keeps the MCP in
283
299
  ![KeyShot MCP generated cube demo](assets/demo/keyshot-mcp-demo.png)
284
300
 
285
301
  The demo uses only the generated geometry in `examples/demo`. To reproduce the
286
- status, import, inspect, camera, save, and PNG-render workflow locally:
302
+ status, import, inspect, two-camera creation, save, discovery, and PNG-render workflow locally:
287
303
 
288
304
  ```bash
289
305
  npm run smoke:keyshot
@@ -321,6 +337,7 @@ MIT
321
337
  - 检查 `.bip` 或 KeyShot 支持的场景文件。
322
338
  - 把场景渲染成图片。
323
339
  - 从同一个场景批量渲染多个指定相机。
340
+ - 自动发现并渲染场景中的全部相机。
324
341
  - 把模型导入场景。
325
342
  - 给对象替换材质。
326
343
  - 创建或更新相机。
@@ -360,7 +377,7 @@ MIT
360
377
  npm install -g keyshot-mcp
361
378
  ```
362
379
 
363
- 当前发布版本为 `0.4.2`。如果你只是想使用 MCP 服务,直接安装 npm 包最简单。
380
+ 当前发布版本为 `0.5.0`。如果你只是想使用 MCP 服务,直接安装 npm 包最简单。
364
381
  电脑仍需要安装 KeyShot Studio,并且已经配置好本地 KeyShot 授权。
365
382
 
366
383
  ### 从源码安装
@@ -383,7 +400,7 @@ npm run build
383
400
  **免安装,直接使用 npx**:
384
401
 
385
402
  ```json
386
- {"mcpServers":{"keyshot":{"command":"npx","args":["-y","keyshot-mcp@0.4.2"],"env":{"KEYSHOT_HEADLESS_EXE":"C:/KeyShot/bin/keyshot_headless.exe"}}}}
403
+ {"mcpServers":{"keyshot":{"command":"npx","args":["-y","keyshot-mcp@0.5.0"],"env":{"KEYSHOT_HEADLESS_EXE":"C:/KeyShot/bin/keyshot_headless.exe"}}}}
387
404
  ```
388
405
 
389
406
  **使用本地源码**(先运行 `npm install && npm run build`):
@@ -397,11 +414,11 @@ npm run build
397
414
  如果你使用 Codex 或其他编程 Agent,可以复制下面这段话,让 Agent 帮你安装和配置 MCP:
398
415
 
399
416
  ```text
400
- 请帮我安装 KeyShot MCP 0.4.2,并添加到我的 MCP 客户端。
417
+ 请帮我安装 KeyShot MCP 0.5.0,并添加到我的 MCP 客户端。
401
418
 
402
419
  请你:
403
420
  1. 查找我电脑上的 KeyShot headless 可执行文件路径。
404
- 2. 使用 npx -y keyshot-mcp@0.4.2 添加 keyshot MCP server。
421
+ 2. 使用 npx -y keyshot-mcp@0.5.0 添加 keyshot MCP server。
405
422
  3. 把 KEYSHOT_HEADLESS_EXE 设置为检测到的 keyshot_headless 路径。
406
423
  4. 用 keyshot_status 工具测试是否配置成功。
407
424
  5. 最后告诉我实际添加的配置,以及状态检查是否通过。
@@ -494,6 +511,11 @@ examples/codex.example.json
494
511
  把场景保存为新文件,并用这个相机渲染一张预览图。
495
512
  ```
496
513
 
514
+ ```text
515
+ 自动找出这个 KeyShot 场景里的全部相机,把所有视角渲染到 all-cameras 文件夹。
516
+ 某个相机失败时继续处理其他相机,最后告诉我每个视角的结果。
517
+ ```
518
+
497
519
  ## 环境变量
498
520
 
499
521
  - `KEYSHOT_HEADLESS_EXE`:`keyshot_headless` 或 `keyshot_headless.exe` 的路径。
@@ -515,6 +537,7 @@ examples/codex.example.json
515
537
  - `keyshot_list_cameras`:列出场景中所有相机名称(批量渲染前很有用)。
516
538
  - `keyshot_render`:渲染单张图片。
517
539
  - `keyshot_batch_render`:批量渲染多个相机视角。
540
+ - `keyshot_render_all_cameras`:自动发现并渲染场景中的全部相机,默认单个视角失败后继续。
518
541
  - `keyshot_render_queue`:顺序执行多个渲染任务(默认遇错即停,设置 `continueOnError` 可继续)。
519
542
  - `keyshot_import_model`:导入模型。
520
543
  - `keyshot_apply_material`:替换材质。
@@ -524,6 +547,13 @@ examples/codex.example.json
524
547
  - `keyshot_set_environment`:设置环境。
525
548
  - `keyshot_save_scene`:保存场景。
526
549
 
550
+ ### 自动渲染全部相机
551
+
552
+ `keyshot_render_all_cameras` 会在同一次 KeyShot 无界面运行中自动读取场景相机并逐个渲染。
553
+ `continueOnError` 默认是 `true`,因此单个相机失败不会阻止其他视角;同名安全文件会自动添加
554
+ `-2`、`-3` 等编号。除非设置 `overwrite: true`,否则不会覆盖已有图片。KeyShot 内部不能激活的
555
+ `last_active` 占位项会被列出并跳过。
556
+
527
557
  ### 材质预设库
528
558
 
529
559
  `keyshot_apply_material_preset` 会读取一个小的 JSON 注册表,让你用"好记的名字"复用材质,
@@ -559,7 +589,7 @@ KeyShot 的 Python `lux` API 会随版本变化。这个 MCP 会尽量保持对
559
589
 
560
590
  ## 路线图
561
591
 
562
- - [ ] 自动发现并批量渲染场景中的所有相机
592
+ - [x] 自动发现并批量渲染场景中的所有相机
563
593
  - [x] 材质预设库
564
594
  - [ ] 相机预设模板
565
595
  - [x] 顺序渲染队列
@@ -573,7 +603,7 @@ KeyShot 的 Python `lux` API 会随版本变化。这个 MCP 会尽量保持对
573
603
  ![KeyShot MCP 自动生成的立方体 Demo](assets/demo/keyshot-mcp-demo.png)
574
604
 
575
605
  Demo 只使用 `examples/demo` 中项目自己生成的几何体,不包含客户模型或第三方素材。
576
- 设置好 `KEYSHOT_HEADLESS_EXE` 后可复现状态检查、导入、检查场景、创建相机、保存和真实 PNG 渲染:
606
+ 设置好 `KEYSHOT_HEADLESS_EXE` 后可复现状态检查、导入、检查场景、创建两个相机、自动发现、保存和真实 PNG 渲染:
577
607
 
578
608
  ```bash
579
609
  npm run smoke:keyshot
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ import { runKeyShotSerialized } from "./runner.js";
7
7
  import { runRenderQueue } from "./queue.js";
8
8
  import { loadMaterialPresets, findMaterialPreset } from "./presets.js";
9
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";
10
+ import { applyMaterialSchema, applyMaterialInputSchema, applyMaterialPresetInputSchema, applyMaterialPresetSchema, batchRenderSchema, batchRenderInputSchema, importModelSchema, listCamerasSchema, listMaterialPresetsSchema, renderQueueSchema, renderQueueInputSchema, renderAllCamerasSchema, renderAllCamerasInputSchema, renderSchema, renderInputSchema, saveSceneSchema, scenePathSchema, setCameraSchema, setEnvironmentSchema, } from "./schemas.js";
11
11
  const config = getConfig();
12
12
  const server = new McpServer({
13
13
  name: "keyshot-mcp",
@@ -72,6 +72,10 @@ server.tool("keyshot_batch_render", "Render multiple named cameras from one KeyS
72
72
  const parsed = batchRenderInputSchema.parse(args);
73
73
  return toolResponse(await runKeyShotSerialized(config, { operation: "batch_render", ...parsed }));
74
74
  });
75
+ server.tool("keyshot_render_all_cameras", "Discover every camera in one KeyShot scene and render each view into an output directory.", renderAllCamerasSchema.shape, async (args) => {
76
+ const parsed = renderAllCamerasInputSchema.parse(args);
77
+ return toolResponse(await runKeyShotSerialized(config, { operation: "render_all_cameras", ...parsed }));
78
+ });
75
79
  server.tool("keyshot_import_model", "Import a model into an optional base scene and save the resulting scene.", importModelSchema.shape, async (args) => toolResponse(await runKeyShotSerialized(config, {
76
80
  operation: "import_model",
77
81
  ...args,
package/dist/schemas.js CHANGED
@@ -36,6 +36,21 @@ export const batchRenderInputSchema = batchRenderSchema.refine((value) => !(valu
36
36
  message: "Choose either samples or maxTimeSeconds, not both.",
37
37
  path: ["maxTimeSeconds"],
38
38
  });
39
+ export const renderAllCamerasSchema = z.object({
40
+ scenePath: z.string().min(1),
41
+ outputDir: z.string().min(1),
42
+ width: z.number().int().positive().optional(),
43
+ height: z.number().int().positive().optional(),
44
+ samples: z.number().int().positive().optional(),
45
+ maxTimeSeconds: z.number().positive().optional(),
46
+ format: imageFormat.optional(),
47
+ overwrite: z.boolean().optional(),
48
+ continueOnError: z.boolean().default(true),
49
+ });
50
+ export const renderAllCamerasInputSchema = renderAllCamerasSchema.refine((value) => !(value.samples !== undefined && value.maxTimeSeconds !== undefined), {
51
+ message: "Choose either samples or maxTimeSeconds, not both.",
52
+ path: ["maxTimeSeconds"],
53
+ });
39
54
  export const importModelSchema = z.object({
40
55
  modelPath: z.string().min(1),
41
56
  baseScenePath: optionalPath,
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.4.2";
1
+ export const VERSION = "0.5.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, creates two MCP
8
+ cameras, automatically discovers them, and renders both views under
9
+ `outputs/demo/all-cameras`. Generated `.bip` and PNG files remain under
10
+ `outputs/` and are ignored by Git.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keyshot-mcp",
3
- "version": "0.4.2",
3
+ "version": "0.5.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",
@@ -1,3 +1,5 @@
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";
3
5
  import { getConfig } from "../dist/config.js";
@@ -18,8 +20,8 @@ async function run(label, request) {
18
20
  return result;
19
21
  }
20
22
 
21
- const status = await run("1/5 KeyShot status", { operation: "status" });
22
- const imported = await run("2/5 Import and save generated OBJ", {
23
+ const status = await run("1/7 KeyShot status", { operation: "status" });
24
+ const imported = await run("2/7 Import and save generated OBJ", {
23
25
  operation: "import_model",
24
26
  modelPath,
25
27
  outputScenePath: "demo/keyshot-mcp-demo-import.bip",
@@ -27,7 +29,7 @@ const imported = await run("2/5 Import and save generated OBJ", {
27
29
  const importedScene = imported.outputFiles[0];
28
30
  if (!importedScene) throw new Error("Import did not return a saved scene path.");
29
31
 
30
- const inspected = await run("3/5 Inspect imported scene", {
32
+ const inspected = await run("3/7 Inspect imported scene", {
31
33
  operation: "inspect_scene",
32
34
  scenePath: importedScene,
33
35
  });
@@ -36,32 +38,70 @@ if (!Array.isArray(objects) || objects.length === 0) {
36
38
  throw new Error("Imported scene did not contain any inspectable objects.");
37
39
  }
38
40
 
39
- const cameraSceneResult = await run("4/5 Create camera and save scene", {
41
+ const firstCameraResult = await run("4/7 Create first camera and save scene", {
40
42
  operation: "set_camera",
41
43
  scenePath: importedScene,
42
- cameraName: "MCP Demo",
44
+ cameraName: "MCP Front",
43
45
  position: [4.5, 3.5, 4.5],
44
46
  lookAt: [0, 0, 0],
45
47
  up: [0, 1, 0],
46
- outputScenePath: "demo/keyshot-mcp-demo-camera.bip",
48
+ outputScenePath: "demo/keyshot-mcp-demo-camera-front.bip",
47
49
  });
48
- const cameraScene = cameraSceneResult.outputFiles[0];
49
- if (!cameraScene) throw new Error("Camera operation did not return a saved scene path.");
50
+ const firstCameraScene = firstCameraResult.outputFiles[0];
51
+ if (!firstCameraScene) throw new Error("First camera operation did not return a saved scene path.");
50
52
 
51
- const rendered = await run("5/5 Render real PNG", {
52
- operation: "render",
53
+ const secondCameraResult = await run("5/7 Create second camera and save scene", {
54
+ operation: "set_camera",
55
+ scenePath: firstCameraScene,
56
+ cameraName: "MCP Angle",
57
+ position: [6.0, 2.5, 3.0],
58
+ lookAt: [0, 0, 0],
59
+ up: [0, 1, 0],
60
+ outputScenePath: "demo/keyshot-mcp-demo-camera-all.bip",
61
+ });
62
+ const cameraScene = secondCameraResult.outputFiles[0];
63
+ if (!cameraScene) throw new Error("Second camera operation did not return a saved scene path.");
64
+
65
+ const rendered = await run("6/7 Discover and render every camera", {
66
+ operation: "render_all_cameras",
53
67
  scenePath: cameraScene,
54
- camera: "MCP Demo",
55
- outputPath: "demo/keyshot-mcp-demo.png",
68
+ outputDir: "demo/all-cameras",
56
69
  width: 640,
57
70
  height: 480,
58
71
  maxTimeSeconds: 8,
59
72
  format: "png",
73
+ overwrite: true,
74
+ continueOnError: true,
60
75
  });
61
76
 
77
+ const renderData = rendered.data;
78
+ if (!renderData || renderData.failed !== 0 || renderData.succeeded < 2) {
79
+ throw new Error("All-camera render did not produce at least two successful camera views.");
80
+ }
81
+
82
+ const namedResults = renderData.results.filter(
83
+ (entry) => entry.camera === "MCP Front" || entry.camera === "MCP Angle",
84
+ );
85
+ if (namedResults.length !== 2 || namedResults.some((entry) => !entry.ok)) {
86
+ throw new Error("The two MCP demo cameras were not both rendered successfully.");
87
+ }
88
+ const hashes = await Promise.all(namedResults.map(async (entry) =>
89
+ crypto.createHash("sha256").update(await fs.readFile(entry.outputPath)).digest("hex")
90
+ ));
91
+ if (hashes[0] === hashes[1]) {
92
+ throw new Error("The two demo camera renders are identical; expected different viewpoints.");
93
+ }
94
+ const minimumSizes = await Promise.all(namedResults.map(async (entry) => (await fs.stat(entry.outputPath)).size));
95
+ if (minimumSizes.some((size) => size < 10000)) {
96
+ throw new Error("A demo camera render is unexpectedly small and may be blank.");
97
+ }
98
+ process.stdout.write("7/7 Verify two different camera images... ok\n");
99
+
62
100
  console.log(JSON.stringify({
63
101
  keyshotVersion: status.data?.version ?? null,
64
102
  objectCount: objects.length,
65
103
  scenePath: cameraScene,
66
- imagePath: rendered.outputFiles[0] ?? null,
104
+ cameraCount: renderData.total,
105
+ renderedImages: rendered.outputFiles,
106
+ verifiedCameras: namedResults.map((entry) => entry.camera),
67
107
  }, null, 2));
@@ -37,6 +37,8 @@ def main():
37
37
  data = render(payload, output_files, warnings)
38
38
  elif operation == "batch_render":
39
39
  data = batch_render(payload, output_files, warnings)
40
+ elif operation == "render_all_cameras":
41
+ data = render_all_cameras(payload, output_files, warnings)
40
42
  elif operation == "import_model":
41
43
  data = import_model(payload, output_files, warnings)
42
44
  elif operation == "apply_material":
@@ -50,7 +52,10 @@ def main():
50
52
  else:
51
53
  raise RuntimeError("Unsupported operation: %s" % operation)
52
54
 
53
- write_result(result_path, True, data, output_files, warnings, None)
55
+ operation_error = None
56
+ if operation == "render_all_cameras" and data.get("failed", 0) > 0:
57
+ operation_error = "%s of %s camera render(s) failed." % (data["failed"], data["total"])
58
+ write_result(result_path, operation_error is None, data, output_files, warnings, operation_error)
54
59
  except Exception as exc:
55
60
  warnings.append(traceback.format_exc())
56
61
  write_result(result_path, False, None, output_files, warnings, str(exc))
@@ -99,6 +104,11 @@ def inspect_scene():
99
104
 
100
105
 
101
106
  def list_cameras():
107
+ names = camera_names()
108
+ return {"cameras": names, "count": len(names)}
109
+
110
+
111
+ def camera_names():
102
112
  raw = safe_list_call("getCameras")
103
113
  names = []
104
114
  for camera in raw:
@@ -113,8 +123,8 @@ def list_cameras():
113
123
  )
114
124
  if name is None:
115
125
  name = repr(camera)
116
- names.append(name)
117
- return {"cameras": names, "count": len(names)}
126
+ names.append(str(name))
127
+ return names
118
128
 
119
129
 
120
130
  def render(payload, output_files, warnings):
@@ -214,6 +224,100 @@ def batch_render(payload, output_files, warnings):
214
224
  }
215
225
 
216
226
 
227
+ def render_all_cameras(payload, output_files, warnings):
228
+ scene_path = payload.get("scenePath")
229
+ output_dir = payload.get("outputDir")
230
+ discovered_cameras = camera_names()
231
+ excluded_cameras = [name for name in discovered_cameras if name.lower() == "last_active"]
232
+ cameras = [name for name in discovered_cameras if name.lower() != "last_active"]
233
+ width = payload.get("width") or 1920
234
+ height = payload.get("height") or 1080
235
+ image_format = payload.get("format") or "png"
236
+ overwrite = bool(payload.get("overwrite", False))
237
+ continue_on_error = payload.get("continueOnError", True) is not False
238
+
239
+ if not scene_path:
240
+ raise RuntimeError("scenePath is required")
241
+ if not output_dir:
242
+ raise RuntimeError("outputDir is required")
243
+ if not cameras:
244
+ raise RuntimeError("No cameras were found in the scene.")
245
+
246
+ if excluded_cameras:
247
+ warnings.append("Skipped KeyShot internal camera placeholder(s): %s" % ", ".join(excluded_cameras))
248
+
249
+ os.makedirs(output_dir, exist_ok=True)
250
+ results = []
251
+ used_stems = set()
252
+ stopped = False
253
+
254
+ for index, camera in enumerate(cameras):
255
+ output_path = unique_camera_output_path(output_dir, camera, image_format, used_stems)
256
+ if stopped:
257
+ results.append(camera_render_result(index, camera, output_path, False, None, True))
258
+ continue
259
+
260
+ try:
261
+ if os.path.exists(output_path) and not overwrite:
262
+ raise RuntimeError("Output already exists and overwrite is false: %s" % output_path)
263
+
264
+ render_payload = dict(payload)
265
+ render_payload["camera"] = camera
266
+ render_payload["outputPath"] = output_path
267
+ render_payload["width"] = width
268
+ render_payload["height"] = height
269
+ render_payload["format"] = image_format
270
+ rendered = render(render_payload, output_files, warnings)
271
+ results.append(camera_render_result(index, camera, rendered.get("rendered"), True, None, False))
272
+ except Exception as exc:
273
+ error = str(exc)
274
+ warnings.append("Camera %s (%s) failed: %s" % (index, camera, error))
275
+ results.append(camera_render_result(index, camera, output_path, False, error, False))
276
+ if not continue_on_error:
277
+ stopped = True
278
+
279
+ succeeded = len([item for item in results if item["ok"]])
280
+ failed = len([item for item in results if not item["ok"] and not item["skipped"]])
281
+ skipped = len([item for item in results if item["skipped"]])
282
+ return {
283
+ "scenePath": scene_path,
284
+ "outputDir": output_dir,
285
+ "cameras": cameras,
286
+ "excludedCameras": excluded_cameras,
287
+ "total": len(cameras),
288
+ "succeeded": succeeded,
289
+ "failed": failed,
290
+ "skipped": skipped,
291
+ "continueOnError": continue_on_error,
292
+ "results": results,
293
+ "width": width,
294
+ "height": height,
295
+ "format": image_format,
296
+ }
297
+
298
+
299
+ def camera_render_result(index, camera, output_path, ok, error, skipped):
300
+ return {
301
+ "index": index,
302
+ "camera": camera,
303
+ "outputPath": output_path,
304
+ "ok": ok,
305
+ "error": error,
306
+ "skipped": skipped,
307
+ }
308
+
309
+
310
+ def unique_camera_output_path(output_dir, camera, image_format, used_stems):
311
+ base = safe_filename(camera)
312
+ stem = base
313
+ suffix = 2
314
+ while stem.lower() in used_stems:
315
+ stem = "%s-%s" % (base, suffix)
316
+ suffix += 1
317
+ used_stems.add(stem.lower())
318
+ return os.path.join(output_dir, "%s.%s" % (stem, image_format))
319
+
320
+
217
321
  def import_model(payload, output_files, warnings):
218
322
  model_path = payload.get("modelPath")
219
323
  output_scene_path = payload.get("outputScenePath")
@@ -298,6 +402,7 @@ def set_camera(payload, output_files, warnings):
298
402
 
299
403
  if position is None or look_at is None:
300
404
  raise RuntimeError("position and lookAt are required")
405
+ direction = tuple(look_at[index] - position[index] for index in range(3))
301
406
 
302
407
  camera = first_camera_object(
303
408
  lambda: lux.getCamera(camera_name),
@@ -305,47 +410,53 @@ def set_camera(payload, output_files, warnings):
305
410
  lambda: lux.createCamera(camera_name),
306
411
  )
307
412
 
308
- if camera is None and hasattr(lux, "saveCamera"):
309
- try:
310
- # 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
413
  if camera is not None:
323
414
  # Object-level API is available: drive the camera object directly.
324
- call_variants("set camera position", lambda: camera.setPosition(tuple(position)))
325
415
  call_variants("set camera look-at", lambda: camera.setLookAt(tuple(look_at)))
416
+ call_variants("set camera position", lambda: camera.setPosition(tuple(position)))
417
+ if hasattr(camera, "setDirection"):
418
+ call_variants("set camera direction", lambda: camera.setDirection(direction))
326
419
  call_variants("set camera up", lambda: camera.setUp(tuple(up)))
327
420
  else:
328
421
  # 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
- )
422
+ # which operate on the active camera. Existing named cameras are activated
423
+ # first. Missing cameras are saved under their new name after the transform
424
+ # is set; saveCamera is a snapshot operation, not a camera constructor.
425
+ if camera_name in camera_names() and hasattr(lux, "setCamera"):
426
+ call_variants("activate camera", lambda: lux.setCamera(camera_name))
337
427
  call_variants(
338
428
  "set camera look-at",
339
429
  lambda: lux.setCameraLookAt(pt=tuple(look_at)),
340
430
  lambda: lux.setCameraLookAt(0, tuple(look_at)),
341
431
  lambda: lux.setCameraLookAt(camera_name, tuple(look_at)),
342
432
  )
433
+ call_variants(
434
+ "set camera position",
435
+ lambda: lux.setCameraPosition(pos=tuple(position)),
436
+ lambda: lux.setCameraPosition(tuple(position)),
437
+ lambda: lux.setCameraPosition(camera_name, tuple(position)),
438
+ )
439
+ if hasattr(lux, "setCameraDirection"):
440
+ call_variants(
441
+ "set camera direction",
442
+ lambda: lux.setCameraDirection(dir=direction),
443
+ lambda: lux.setCameraDirection(direction),
444
+ )
343
445
  call_variants(
344
446
  "set camera up",
345
447
  lambda: lux.setCameraUp(up=tuple(up)),
346
448
  lambda: lux.setCameraUp(tuple(up)),
347
449
  lambda: lux.setCameraUp(camera_name, tuple(up)),
348
450
  )
451
+ if hasattr(lux, "saveCamera"):
452
+ try:
453
+ call_variants(
454
+ "save named camera",
455
+ lambda: lux.saveCamera(),
456
+ lambda: lux.saveCamera(camera_name),
457
+ )
458
+ except RuntimeError:
459
+ warnings.append("The active camera was updated, but this KeyShot version could not save the named view.")
349
460
 
350
461
  save_to(output_scene_path)
351
462
  output_files.append(output_scene_path)
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.5.0",
13
13
  "packages": [
14
14
  {
15
15
  "registryType": "npm",
16
16
  "identifier": "keyshot-mcp",
17
- "version": "0.4.2",
17
+ "version": "0.5.0",
18
18
  "transport": {
19
19
  "type": "stdio"
20
20
  },