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 +42 -12
- package/dist/index.js +5 -1
- package/dist/schemas.js +15 -0
- package/dist/version.js +1 -1
- package/examples/demo/README.md +4 -3
- package/package.json +1 -1
- package/scripts/keyshot-smoke.mjs +53 -13
- package/scripts/keyshot_bridge.py +137 -26
- package/server.json +2 -2
package/README.md
CHANGED
|
@@ -28,6 +28,7 @@ This project does not include KeyShot, does not bypass licensing, and does not s
|
|
|
28
28
|
- Inspect a `.bip` or supported KeyShot scene file.
|
|
29
29
|
- Render a scene to an image.
|
|
30
30
|
- Batch render multiple named cameras from one scene.
|
|
31
|
+
- Automatically discover and render every camera in one scene.
|
|
31
32
|
- Import a model into a scene.
|
|
32
33
|
- Apply a material to an object.
|
|
33
34
|
- Create or update a camera.
|
|
@@ -67,7 +68,7 @@ This project does not include KeyShot, does not bypass licensing, and does not s
|
|
|
67
68
|
npm install -g keyshot-mcp
|
|
68
69
|
```
|
|
69
70
|
|
|
70
|
-
The current release line is `0.
|
|
71
|
+
The current release line is `0.5.0`. The published package is the easiest option
|
|
71
72
|
when you only want to use the MCP server. You still need KeyShot Studio and a
|
|
72
73
|
local KeyShot license.
|
|
73
74
|
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
-
- [
|
|
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
|

|
|
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.
|
|
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.
|
|
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.
|
|
417
|
+
请帮我安装 KeyShot MCP 0.5.0,并添加到我的 MCP 客户端。
|
|
401
418
|
|
|
402
419
|
请你:
|
|
403
420
|
1. 查找我电脑上的 KeyShot headless 可执行文件路径。
|
|
404
|
-
2. 使用 npx -y keyshot-mcp@0.
|
|
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
|

|
|
574
604
|
|
|
575
605
|
Demo 只使用 `examples/demo` 中项目自己生成的几何体,不包含客户模型或第三方素材。
|
|
576
|
-
设置好 `KEYSHOT_HEADLESS_EXE`
|
|
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.
|
|
1
|
+
export const VERSION = "0.5.0";
|
package/examples/demo/README.md
CHANGED
|
@@ -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
|
|
8
|
-
|
|
9
|
-
|
|
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,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/
|
|
22
|
-
const imported = await run("2/
|
|
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/
|
|
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
|
|
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
|
|
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
|
|
49
|
-
if (!
|
|
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
|
|
52
|
-
operation: "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
55
|
+
operation_error = None
|
|
56
|
+
if operation == "render_all_cameras" and data.get("failed", 0) > 0:
|
|
57
|
+
operation_error = "%s of %s camera render(s) failed." % (data["failed"], data["total"])
|
|
58
|
+
write_result(result_path, operation_error is None, data, output_files, warnings, operation_error)
|
|
54
59
|
except Exception as exc:
|
|
55
60
|
warnings.append(traceback.format_exc())
|
|
56
61
|
write_result(result_path, False, None, output_files, warnings, str(exc))
|
|
@@ -99,6 +104,11 @@ def inspect_scene():
|
|
|
99
104
|
|
|
100
105
|
|
|
101
106
|
def list_cameras():
|
|
107
|
+
names = camera_names()
|
|
108
|
+
return {"cameras": names, "count": len(names)}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def camera_names():
|
|
102
112
|
raw = safe_list_call("getCameras")
|
|
103
113
|
names = []
|
|
104
114
|
for camera in raw:
|
|
@@ -113,8 +123,8 @@ def list_cameras():
|
|
|
113
123
|
)
|
|
114
124
|
if name is None:
|
|
115
125
|
name = repr(camera)
|
|
116
|
-
names.append(name)
|
|
117
|
-
return
|
|
126
|
+
names.append(str(name))
|
|
127
|
+
return names
|
|
118
128
|
|
|
119
129
|
|
|
120
130
|
def render(payload, output_files, warnings):
|
|
@@ -214,6 +224,100 @@ def batch_render(payload, output_files, warnings):
|
|
|
214
224
|
}
|
|
215
225
|
|
|
216
226
|
|
|
227
|
+
def render_all_cameras(payload, output_files, warnings):
|
|
228
|
+
scene_path = payload.get("scenePath")
|
|
229
|
+
output_dir = payload.get("outputDir")
|
|
230
|
+
discovered_cameras = camera_names()
|
|
231
|
+
excluded_cameras = [name for name in discovered_cameras if name.lower() == "last_active"]
|
|
232
|
+
cameras = [name for name in discovered_cameras if name.lower() != "last_active"]
|
|
233
|
+
width = payload.get("width") or 1920
|
|
234
|
+
height = payload.get("height") or 1080
|
|
235
|
+
image_format = payload.get("format") or "png"
|
|
236
|
+
overwrite = bool(payload.get("overwrite", False))
|
|
237
|
+
continue_on_error = payload.get("continueOnError", True) is not False
|
|
238
|
+
|
|
239
|
+
if not scene_path:
|
|
240
|
+
raise RuntimeError("scenePath is required")
|
|
241
|
+
if not output_dir:
|
|
242
|
+
raise RuntimeError("outputDir is required")
|
|
243
|
+
if not cameras:
|
|
244
|
+
raise RuntimeError("No cameras were found in the scene.")
|
|
245
|
+
|
|
246
|
+
if excluded_cameras:
|
|
247
|
+
warnings.append("Skipped KeyShot internal camera placeholder(s): %s" % ", ".join(excluded_cameras))
|
|
248
|
+
|
|
249
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
250
|
+
results = []
|
|
251
|
+
used_stems = set()
|
|
252
|
+
stopped = False
|
|
253
|
+
|
|
254
|
+
for index, camera in enumerate(cameras):
|
|
255
|
+
output_path = unique_camera_output_path(output_dir, camera, image_format, used_stems)
|
|
256
|
+
if stopped:
|
|
257
|
+
results.append(camera_render_result(index, camera, output_path, False, None, True))
|
|
258
|
+
continue
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
if os.path.exists(output_path) and not overwrite:
|
|
262
|
+
raise RuntimeError("Output already exists and overwrite is false: %s" % output_path)
|
|
263
|
+
|
|
264
|
+
render_payload = dict(payload)
|
|
265
|
+
render_payload["camera"] = camera
|
|
266
|
+
render_payload["outputPath"] = output_path
|
|
267
|
+
render_payload["width"] = width
|
|
268
|
+
render_payload["height"] = height
|
|
269
|
+
render_payload["format"] = image_format
|
|
270
|
+
rendered = render(render_payload, output_files, warnings)
|
|
271
|
+
results.append(camera_render_result(index, camera, rendered.get("rendered"), True, None, False))
|
|
272
|
+
except Exception as exc:
|
|
273
|
+
error = str(exc)
|
|
274
|
+
warnings.append("Camera %s (%s) failed: %s" % (index, camera, error))
|
|
275
|
+
results.append(camera_render_result(index, camera, output_path, False, error, False))
|
|
276
|
+
if not continue_on_error:
|
|
277
|
+
stopped = True
|
|
278
|
+
|
|
279
|
+
succeeded = len([item for item in results if item["ok"]])
|
|
280
|
+
failed = len([item for item in results if not item["ok"] and not item["skipped"]])
|
|
281
|
+
skipped = len([item for item in results if item["skipped"]])
|
|
282
|
+
return {
|
|
283
|
+
"scenePath": scene_path,
|
|
284
|
+
"outputDir": output_dir,
|
|
285
|
+
"cameras": cameras,
|
|
286
|
+
"excludedCameras": excluded_cameras,
|
|
287
|
+
"total": len(cameras),
|
|
288
|
+
"succeeded": succeeded,
|
|
289
|
+
"failed": failed,
|
|
290
|
+
"skipped": skipped,
|
|
291
|
+
"continueOnError": continue_on_error,
|
|
292
|
+
"results": results,
|
|
293
|
+
"width": width,
|
|
294
|
+
"height": height,
|
|
295
|
+
"format": image_format,
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def camera_render_result(index, camera, output_path, ok, error, skipped):
|
|
300
|
+
return {
|
|
301
|
+
"index": index,
|
|
302
|
+
"camera": camera,
|
|
303
|
+
"outputPath": output_path,
|
|
304
|
+
"ok": ok,
|
|
305
|
+
"error": error,
|
|
306
|
+
"skipped": skipped,
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def unique_camera_output_path(output_dir, camera, image_format, used_stems):
|
|
311
|
+
base = safe_filename(camera)
|
|
312
|
+
stem = base
|
|
313
|
+
suffix = 2
|
|
314
|
+
while stem.lower() in used_stems:
|
|
315
|
+
stem = "%s-%s" % (base, suffix)
|
|
316
|
+
suffix += 1
|
|
317
|
+
used_stems.add(stem.lower())
|
|
318
|
+
return os.path.join(output_dir, "%s.%s" % (stem, image_format))
|
|
319
|
+
|
|
320
|
+
|
|
217
321
|
def import_model(payload, output_files, warnings):
|
|
218
322
|
model_path = payload.get("modelPath")
|
|
219
323
|
output_scene_path = payload.get("outputScenePath")
|
|
@@ -298,6 +402,7 @@ def set_camera(payload, output_files, warnings):
|
|
|
298
402
|
|
|
299
403
|
if position is None or look_at is None:
|
|
300
404
|
raise RuntimeError("position and lookAt are required")
|
|
405
|
+
direction = tuple(look_at[index] - position[index] for index in range(3))
|
|
301
406
|
|
|
302
407
|
camera = first_camera_object(
|
|
303
408
|
lambda: lux.getCamera(camera_name),
|
|
@@ -305,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
|
|
330
|
-
#
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
lambda: lux.
|
|
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.
|
|
12
|
+
"version": "0.5.0",
|
|
13
13
|
"packages": [
|
|
14
14
|
{
|
|
15
15
|
"registryType": "npm",
|
|
16
16
|
"identifier": "keyshot-mcp",
|
|
17
|
-
"version": "0.
|
|
17
|
+
"version": "0.5.0",
|
|
18
18
|
"transport": {
|
|
19
19
|
"type": "stdio"
|
|
20
20
|
},
|