keyshot-mcp 0.2.1 → 0.4.1
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 +101 -6
- package/dist/config.js +1 -0
- package/dist/index.js +61 -6
- package/dist/presets.js +67 -0
- package/dist/queue.js +70 -0
- package/dist/runner.js +46 -33
- package/dist/schemas.js +45 -0
- package/package.json +5 -2
- package/presets/materials.json +14 -0
- package/scripts/keyshot_bridge.py +176 -35
package/README.md
CHANGED
|
@@ -61,6 +61,18 @@ This project does not include KeyShot, does not bypass licensing, and does not s
|
|
|
61
61
|
|
|
62
62
|
## Install
|
|
63
63
|
|
|
64
|
+
### Install the published npm package
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
npm install -g keyshot-mcp
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The current release line is `0.4.1`. The published package is the easiest option
|
|
71
|
+
when you only want to use the MCP server. You still need KeyShot Studio and a
|
|
72
|
+
local KeyShot license.
|
|
73
|
+
|
|
74
|
+
### Install from source
|
|
75
|
+
|
|
64
76
|
```bash
|
|
65
77
|
npm install
|
|
66
78
|
npm run build
|
|
@@ -165,6 +177,11 @@ Import the model file at /path/to/model.step, apply a brushed metal material to
|
|
|
165
177
|
Inspect this KeyShot scene and summarize the available objects, cameras, materials, and renderable outputs.
|
|
166
178
|
```
|
|
167
179
|
|
|
180
|
+
```text
|
|
181
|
+
List the cameras in this KeyShot scene, create or update a camera named "Hero",
|
|
182
|
+
save the scene to a new file, and render one preview from that camera.
|
|
183
|
+
```
|
|
184
|
+
|
|
168
185
|
## Environment Variables
|
|
169
186
|
|
|
170
187
|
- `KEYSHOT_HEADLESS_EXE`: path to `keyshot_headless` or `keyshot_headless.exe`.
|
|
@@ -172,18 +189,41 @@ Inspect this KeyShot scene and summarize the available objects, cameras, materia
|
|
|
172
189
|
- `KEYSHOT_LICENSE_ARGS`: optional KeyShot headless license arguments. Empty by default.
|
|
173
190
|
- `KEYSHOT_TIMEOUT_MS`: operation timeout in milliseconds. Default: `600000`.
|
|
174
191
|
|
|
192
|
+
`samples` and `maxTimeSeconds` select different KeyShot render modes. Provide one
|
|
193
|
+
or the other, not both.
|
|
194
|
+
|
|
175
195
|
## MCP Tools
|
|
176
196
|
|
|
177
197
|
- `keyshot_status`
|
|
178
198
|
- `keyshot_inspect_scene`
|
|
199
|
+
- `keyshot_list_cameras`: list available camera names in a scene (handy before batch rendering)
|
|
179
200
|
- `keyshot_render`: render a single image
|
|
180
201
|
- `keyshot_batch_render`
|
|
202
|
+
- `keyshot_render_queue`: run several render jobs sequentially (stops at first failure unless `continueOnError`)
|
|
181
203
|
- `keyshot_import_model`
|
|
182
204
|
- `keyshot_apply_material`
|
|
205
|
+
- `keyshot_list_material_presets`: list presets from the material preset library
|
|
206
|
+
- `keyshot_apply_material_preset`: apply a named preset from the library to an object
|
|
183
207
|
- `keyshot_set_camera`
|
|
184
208
|
- `keyshot_set_environment`
|
|
185
209
|
- `keyshot_save_scene`
|
|
186
210
|
|
|
211
|
+
### Material preset library
|
|
212
|
+
|
|
213
|
+
`keyshot_apply_material_preset` reads a small JSON registry so you can reuse named
|
|
214
|
+
looks instead of remembering exact KeyShot material names. Default location:
|
|
215
|
+
`presets/materials.json` (override with the `KEYSHOT_MATERIAL_PRESETS` env var).
|
|
216
|
+
|
|
217
|
+
```json
|
|
218
|
+
{
|
|
219
|
+
"Brushed Steel": { "materialName": "Steel Brushed", "description": "metal parts" },
|
|
220
|
+
"Clear Glass": { "materialPath": "C:/materials/glass_clear.mtl" }
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Each preset must have a `materialName` or a `materialPath`. Use
|
|
225
|
+
`keyshot_list_material_presets` to see what is available.
|
|
226
|
+
|
|
187
227
|
## MCP Prompts and Resources
|
|
188
228
|
|
|
189
229
|
- Prompt: `keyshot_product_render`
|
|
@@ -205,13 +245,13 @@ KeyShot's Python `lux` API changes across versions. This server keeps the MCP in
|
|
|
205
245
|
## Roadmap
|
|
206
246
|
|
|
207
247
|
- [ ] Auto-discover and batch render all cameras in a scene
|
|
208
|
-
- [
|
|
248
|
+
- [x] Material preset library
|
|
209
249
|
- [ ] Camera preset templates
|
|
210
|
-
- [
|
|
250
|
+
- [x] Sequential render queue
|
|
211
251
|
- [ ] Safer output directory restrictions
|
|
212
252
|
- [ ] More tested KeyShot versions
|
|
213
253
|
- [ ] macOS compatibility verification
|
|
214
|
-
- [
|
|
254
|
+
- [x] Claude Desktop / Cursor / Codex config examples
|
|
215
255
|
|
|
216
256
|
## License
|
|
217
257
|
|
|
@@ -278,6 +318,17 @@ MIT
|
|
|
278
318
|
|
|
279
319
|
## 安装
|
|
280
320
|
|
|
321
|
+
### 安装已发布的 npm 包
|
|
322
|
+
|
|
323
|
+
```bash
|
|
324
|
+
npm install -g keyshot-mcp
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
当前发布版本为 `0.4.1`。如果你只是想使用 MCP 服务,直接安装 npm 包最简单。
|
|
328
|
+
电脑仍需要安装 KeyShot Studio,并且已经配置好本地 KeyShot 授权。
|
|
329
|
+
|
|
330
|
+
### 从源码安装
|
|
331
|
+
|
|
281
332
|
```bash
|
|
282
333
|
npm install
|
|
283
334
|
npm run build
|
|
@@ -382,25 +433,52 @@ examples/codex.example.json
|
|
|
382
433
|
检查当前 KeyShot 场景,并总结场景中的对象、相机、材质和可渲染输出。
|
|
383
434
|
```
|
|
384
435
|
|
|
436
|
+
```text
|
|
437
|
+
列出当前 KeyShot 场景中的相机,创建或更新一个名为“Hero”的相机,
|
|
438
|
+
把场景保存为新文件,并用这个相机渲染一张预览图。
|
|
439
|
+
```
|
|
440
|
+
|
|
385
441
|
## 环境变量
|
|
386
442
|
|
|
387
443
|
- `KEYSHOT_HEADLESS_EXE`:`keyshot_headless` 或 `keyshot_headless.exe` 的路径。
|
|
388
444
|
- `KEYSHOT_OUTPUT_DIR`:默认渲染输出文件夹。
|
|
389
445
|
- `KEYSHOT_LICENSE_ARGS`:可选的 KeyShot 无界面许可证参数,默认留空。
|
|
390
446
|
- `KEYSHOT_TIMEOUT_MS`:单次操作超时时间,单位毫秒,默认 `600000`。
|
|
447
|
+
- `KEYSHOT_MATERIAL_PRESETS`:材质预设库 JSON 文件路径,默认 `presets/materials.json`。
|
|
448
|
+
|
|
449
|
+
`samples` 和 `maxTimeSeconds` 是两种不同的 KeyShot 渲染模式,请二选一,不要同时传入。
|
|
391
450
|
|
|
392
451
|
## MCP 工具
|
|
393
452
|
|
|
394
453
|
- `keyshot_status`:检查 KeyShot 是否能启动。
|
|
395
454
|
- `keyshot_inspect_scene`:检查场景内容。
|
|
455
|
+
- `keyshot_list_cameras`:列出场景中所有相机名称(批量渲染前很有用)。
|
|
396
456
|
- `keyshot_render`:渲染单张图片。
|
|
397
457
|
- `keyshot_batch_render`:批量渲染多个相机视角。
|
|
458
|
+
- `keyshot_render_queue`:顺序执行多个渲染任务(默认遇错即停,设置 `continueOnError` 可继续)。
|
|
398
459
|
- `keyshot_import_model`:导入模型。
|
|
399
460
|
- `keyshot_apply_material`:替换材质。
|
|
461
|
+
- `keyshot_list_material_presets`:列出材质预设库中的预设。
|
|
462
|
+
- `keyshot_apply_material_preset`:把预设库中的某个命名材质应用到物体上。
|
|
400
463
|
- `keyshot_set_camera`:设置相机。
|
|
401
464
|
- `keyshot_set_environment`:设置环境。
|
|
402
465
|
- `keyshot_save_scene`:保存场景。
|
|
403
466
|
|
|
467
|
+
### 材质预设库
|
|
468
|
+
|
|
469
|
+
`keyshot_apply_material_preset` 会读取一个小的 JSON 注册表,让你用"好记的名字"复用材质,
|
|
470
|
+
而不必记住 KeyShot 里精确的材质名。默认位置:`presets/materials.json`
|
|
471
|
+
(可用环境变量 `KEYSHOT_MATERIAL_PRESETS` 覆盖)。
|
|
472
|
+
|
|
473
|
+
```json
|
|
474
|
+
{
|
|
475
|
+
"拉丝钢": { "materialName": "Steel Brushed", "description": "金属件默认材质" },
|
|
476
|
+
"透明玻璃": { "materialPath": "C:/materials/glass_clear.mtl" }
|
|
477
|
+
}
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
每个预设必须包含 `materialName` 或 `materialPath`。用 `keyshot_list_material_presets` 查看有哪些预设。
|
|
481
|
+
|
|
404
482
|
## MCP 提示词和资源
|
|
405
483
|
|
|
406
484
|
- 提示词:`keyshot_product_render`
|
|
@@ -422,13 +500,30 @@ KeyShot 的 Python `lux` API 会随版本变化。这个 MCP 会尽量保持对
|
|
|
422
500
|
## 路线图
|
|
423
501
|
|
|
424
502
|
- [ ] 自动发现并批量渲染场景中的所有相机
|
|
425
|
-
- [
|
|
503
|
+
- [x] 材质预设库
|
|
426
504
|
- [ ] 相机预设模板
|
|
427
|
-
- [
|
|
505
|
+
- [x] 顺序渲染队列
|
|
428
506
|
- [ ] 更安全的输出目录限制
|
|
429
507
|
- [ ] 测试更多 KeyShot 版本
|
|
430
508
|
- [ ] 验证 macOS 兼容性
|
|
431
|
-
- [
|
|
509
|
+
- [x] 补充 Claude Desktop / Cursor / Codex 配置示例
|
|
510
|
+
|
|
511
|
+
## 测试
|
|
512
|
+
|
|
513
|
+
项目带了两套测试,不依赖真实的 KeyShot 也能跑:
|
|
514
|
+
|
|
515
|
+
- **TypeScript 测试(Vitest)**:覆盖配置读取、参数校验(schemas)、结果封装,以及用"假 KeyShot"跑通一次成功路径并验证 `work/tmp` 临时文件会被清理。
|
|
516
|
+
|
|
517
|
+
```bash
|
|
518
|
+
npm install
|
|
519
|
+
npm test
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
- **Python bridge 测试(unittest)**:用假的 `lux` 对象验证 `import_model` 会真正打开基础场景、`set_camera` 在拿不到相机对象时不会崩溃并回退到 `lux` 级 API。
|
|
523
|
+
|
|
524
|
+
```bash
|
|
525
|
+
python tests/test_bridge.py
|
|
526
|
+
```
|
|
432
527
|
|
|
433
528
|
## 开源协议
|
|
434
529
|
|
package/dist/config.js
CHANGED
|
@@ -13,6 +13,7 @@ export function getConfig() {
|
|
|
13
13
|
keyshotTimeoutMs: parsePositiveInt(process.env.KEYSHOT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS),
|
|
14
14
|
tmpDir: path.join(projectRoot, "work", "tmp"),
|
|
15
15
|
bridgeScriptPath: path.join(projectRoot, "scripts", "keyshot_bridge.py"),
|
|
16
|
+
materialPresetsPath: path.resolve(process.env.KEYSHOT_MATERIAL_PRESETS ?? path.join(projectRoot, "presets", "materials.json")),
|
|
16
17
|
};
|
|
17
18
|
}
|
|
18
19
|
function parsePositiveInt(value, fallback) {
|
package/dist/index.js
CHANGED
|
@@ -2,14 +2,19 @@
|
|
|
2
2
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
4
|
import { getConfig } from "./config.js";
|
|
5
|
-
import { toolResponse } from "./result.js";
|
|
5
|
+
import { toolResponse, localFailure } from "./result.js";
|
|
6
6
|
import { runKeyShotSerialized } from "./runner.js";
|
|
7
|
-
import {
|
|
7
|
+
import { runRenderQueue } from "./queue.js";
|
|
8
|
+
import { loadMaterialPresets, findMaterialPreset } from "./presets.js";
|
|
9
|
+
import { applyMaterialSchema, applyMaterialInputSchema, applyMaterialPresetInputSchema, applyMaterialPresetSchema, batchRenderSchema, batchRenderInputSchema, importModelSchema, listCamerasSchema, listMaterialPresetsSchema, renderQueueSchema, renderQueueInputSchema, renderSchema, renderInputSchema, saveSceneSchema, scenePathSchema, setCameraSchema, setEnvironmentSchema, } from "./schemas.js";
|
|
8
10
|
const config = getConfig();
|
|
9
11
|
const server = new McpServer({
|
|
10
12
|
name: "keyshot-mcp",
|
|
11
|
-
version: "0.
|
|
13
|
+
version: "0.4.1",
|
|
12
14
|
});
|
|
15
|
+
function errorMessage(error) {
|
|
16
|
+
return error instanceof Error ? error.message : String(error);
|
|
17
|
+
}
|
|
13
18
|
server.registerResource("keyshot-workflow", "keyshot://workflow", {
|
|
14
19
|
title: "KeyShot MCP Workflow",
|
|
15
20
|
description: "How this MCP server connects AI agents to KeyShot headless scripting.",
|
|
@@ -53,17 +58,67 @@ server.registerPrompt("keyshot_product_render", {
|
|
|
53
58
|
}));
|
|
54
59
|
server.tool("keyshot_status", "Check KeyShot headless availability and script startup.", {}, async () => toolResponse(await runKeyShotSerialized(config, { operation: "status" })));
|
|
55
60
|
server.tool("keyshot_inspect_scene", "Open a KeyShot scene and return available objects, cameras, materials and scene metadata.", scenePathSchema.shape, async (args) => toolResponse(await runKeyShotSerialized(config, { operation: "inspect_scene", ...args })));
|
|
56
|
-
server.tool("
|
|
57
|
-
server.tool("
|
|
61
|
+
server.tool("keyshot_list_cameras", "Open a KeyShot scene and return the list of available camera names (useful before batch rendering).", listCamerasSchema.shape, async (args) => toolResponse(await runKeyShotSerialized(config, { operation: "list_cameras", ...args })));
|
|
62
|
+
server.tool("keyshot_render", "Render a KeyShot scene to an image file.", renderSchema.shape, async (args) => {
|
|
63
|
+
const parsed = renderInputSchema.parse(args);
|
|
64
|
+
return toolResponse(await runKeyShotSerialized(config, { operation: "render", ...parsed }));
|
|
65
|
+
});
|
|
66
|
+
server.tool("keyshot_render_queue", "Render several jobs sequentially. Stops at the first failure unless continueOnError is set.", renderQueueSchema.shape, async (args) => {
|
|
67
|
+
const parsed = renderQueueInputSchema.parse(args);
|
|
68
|
+
return toolResponse(await runRenderQueue(config, parsed.jobs, { continueOnError: parsed.continueOnError ?? false }));
|
|
69
|
+
});
|
|
70
|
+
server.tool("keyshot_batch_render", "Render multiple named cameras from one KeyShot scene into an output directory.", batchRenderSchema.shape, async (args) => {
|
|
71
|
+
const parsed = batchRenderInputSchema.parse(args);
|
|
72
|
+
return toolResponse(await runKeyShotSerialized(config, { operation: "batch_render", ...parsed }));
|
|
73
|
+
});
|
|
58
74
|
server.tool("keyshot_import_model", "Import a model into an optional base scene and save the resulting scene.", importModelSchema.shape, async (args) => toolResponse(await runKeyShotSerialized(config, {
|
|
59
75
|
operation: "import_model",
|
|
60
|
-
scenePath: args.baseScenePath,
|
|
61
76
|
...args,
|
|
62
77
|
})));
|
|
63
78
|
server.tool("keyshot_apply_material", "Apply a material by name or material file to a scene object and save the resulting scene.", applyMaterialInputSchema.shape, async (args) => {
|
|
64
79
|
const parsed = applyMaterialSchema.parse(args);
|
|
65
80
|
return toolResponse(await runKeyShotSerialized(config, { operation: "apply_material", ...parsed }));
|
|
66
81
|
});
|
|
82
|
+
server.tool("keyshot_list_material_presets", "List material presets from the local preset library (presets/materials.json or KEYSHOT_MATERIAL_PRESETS).", listMaterialPresetsSchema.shape, async () => {
|
|
83
|
+
try {
|
|
84
|
+
const presets = await loadMaterialPresets(config);
|
|
85
|
+
return toolResponse({
|
|
86
|
+
ok: true,
|
|
87
|
+
data: { presets, count: presets.length, source: config.materialPresetsPath },
|
|
88
|
+
outputFiles: [],
|
|
89
|
+
warnings: presets.length === 0 ? ["No material presets found. Create presets/materials.json to add some."] : [],
|
|
90
|
+
keyshotStdoutTail: "",
|
|
91
|
+
error: null,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
return toolResponse(localFailure(errorMessage(error)));
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
server.tool("keyshot_apply_material_preset", "Apply a named material preset (from the preset library) to a scene object and save the resulting scene.", applyMaterialPresetInputSchema.shape, async (args) => {
|
|
99
|
+
const parsed = applyMaterialPresetSchema.parse(args);
|
|
100
|
+
let presets;
|
|
101
|
+
try {
|
|
102
|
+
presets = await loadMaterialPresets(config);
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
return toolResponse(localFailure(errorMessage(error)));
|
|
106
|
+
}
|
|
107
|
+
const preset = findMaterialPreset(presets, parsed.presetName);
|
|
108
|
+
if (!preset) {
|
|
109
|
+
const available = presets.map((entry) => entry.name).join(", ") || "(none)";
|
|
110
|
+
return toolResponse(localFailure(`Material preset not found: "${parsed.presetName}". Available: ${available}`));
|
|
111
|
+
}
|
|
112
|
+
return toolResponse(await runKeyShotSerialized(config, {
|
|
113
|
+
operation: "apply_material",
|
|
114
|
+
scenePath: parsed.scenePath,
|
|
115
|
+
objectName: parsed.objectName,
|
|
116
|
+
objectPath: parsed.objectPath,
|
|
117
|
+
materialName: preset.materialName,
|
|
118
|
+
materialPath: preset.materialPath,
|
|
119
|
+
outputScenePath: parsed.outputScenePath,
|
|
120
|
+
}));
|
|
121
|
+
});
|
|
67
122
|
server.tool("keyshot_set_camera", "Create or update a camera from position/look-at vectors and save the resulting scene.", setCameraSchema.shape, async (args) => toolResponse(await runKeyShotSerialized(config, { operation: "set_camera", ...args })));
|
|
68
123
|
server.tool("keyshot_set_environment", "Set a scene environment by name or file when supported by KeyShot headless scripting.", setEnvironmentSchema.shape, async (args) => toolResponse(await runKeyShotSerialized(config, { operation: "set_environment", ...args })));
|
|
69
124
|
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 })));
|
package/dist/presets.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
/**
|
|
3
|
+
* Load the material preset library from disk.
|
|
4
|
+
*
|
|
5
|
+
* The registry file (see config.materialPresetsPath, overridable with the
|
|
6
|
+
* KEYSHOT_MATERIAL_PRESETS env var) may use either of two shapes:
|
|
7
|
+
*
|
|
8
|
+
* 1. Object keyed by preset name:
|
|
9
|
+
* { "Brushed Steel": { "materialName": "Steel Brushed", "description": "..." } }
|
|
10
|
+
*
|
|
11
|
+
* 2. Array of presets:
|
|
12
|
+
* [ { "name": "Brushed Steel", "materialName": "Steel Brushed" } ]
|
|
13
|
+
*
|
|
14
|
+
* A missing file is treated as an empty library (not an error), so the feature
|
|
15
|
+
* works out of the box before the user creates any presets.
|
|
16
|
+
*/
|
|
17
|
+
export async function loadMaterialPresets(config) {
|
|
18
|
+
let raw;
|
|
19
|
+
try {
|
|
20
|
+
raw = await fs.readFile(config.materialPresetsPath, "utf8");
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
let parsed;
|
|
26
|
+
try {
|
|
27
|
+
parsed = JSON.parse(raw);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
throw new Error(`Material presets file is not valid JSON: ${config.materialPresetsPath}`);
|
|
31
|
+
}
|
|
32
|
+
return normalizePresets(parsed);
|
|
33
|
+
}
|
|
34
|
+
export function normalizePresets(parsed) {
|
|
35
|
+
const entries = [];
|
|
36
|
+
if (Array.isArray(parsed)) {
|
|
37
|
+
for (const item of parsed) {
|
|
38
|
+
if (item && typeof item === "object") {
|
|
39
|
+
const record = item;
|
|
40
|
+
const name = typeof record.name === "string" ? record.name : undefined;
|
|
41
|
+
if (name)
|
|
42
|
+
entries.push(toPreset(name, record));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
else if (parsed && typeof parsed === "object") {
|
|
47
|
+
for (const [name, value] of Object.entries(parsed)) {
|
|
48
|
+
if (value && typeof value === "object") {
|
|
49
|
+
entries.push(toPreset(name, value));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return entries.filter((preset) => preset.materialName || preset.materialPath);
|
|
54
|
+
}
|
|
55
|
+
function toPreset(name, record) {
|
|
56
|
+
return {
|
|
57
|
+
name,
|
|
58
|
+
materialName: typeof record.materialName === "string" ? record.materialName : undefined,
|
|
59
|
+
materialPath: typeof record.materialPath === "string" ? record.materialPath : undefined,
|
|
60
|
+
description: typeof record.description === "string" ? record.description : undefined,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
export function findMaterialPreset(presets, name) {
|
|
64
|
+
const lower = name.toLowerCase();
|
|
65
|
+
return (presets.find((preset) => preset.name === name) ??
|
|
66
|
+
presets.find((preset) => preset.name.toLowerCase() === lower));
|
|
67
|
+
}
|
package/dist/queue.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { runKeyShotSerialized } from "./runner.js";
|
|
2
|
+
/**
|
|
3
|
+
* Run several render jobs one after another.
|
|
4
|
+
*
|
|
5
|
+
* Jobs execute sequentially (the underlying runner is already serialized, and a
|
|
6
|
+
* single KeyShot instance cannot render in parallel). By default the queue stops
|
|
7
|
+
* at the first failure; pass continueOnError to render every remaining job and
|
|
8
|
+
* collect all outcomes. Jobs that are skipped after an early stop are reported
|
|
9
|
+
* with skipped: true so the caller can see exactly what ran.
|
|
10
|
+
*/
|
|
11
|
+
export async function runRenderQueue(config, jobs, options = {}, runFn = runKeyShotSerialized) {
|
|
12
|
+
const continueOnError = options.continueOnError ?? false;
|
|
13
|
+
const results = [];
|
|
14
|
+
const outputFiles = [];
|
|
15
|
+
const warnings = [];
|
|
16
|
+
let succeeded = 0;
|
|
17
|
+
let failed = 0;
|
|
18
|
+
let stopped = false;
|
|
19
|
+
for (let index = 0; index < jobs.length; index += 1) {
|
|
20
|
+
const job = jobs[index];
|
|
21
|
+
if (stopped) {
|
|
22
|
+
results.push({
|
|
23
|
+
index,
|
|
24
|
+
scenePath: job.scenePath,
|
|
25
|
+
camera: job.camera,
|
|
26
|
+
ok: false,
|
|
27
|
+
outputFiles: [],
|
|
28
|
+
error: null,
|
|
29
|
+
skipped: true,
|
|
30
|
+
});
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
const result = await runFn(config, { operation: "render", ...job });
|
|
34
|
+
results.push({
|
|
35
|
+
index,
|
|
36
|
+
scenePath: job.scenePath,
|
|
37
|
+
camera: job.camera,
|
|
38
|
+
ok: result.ok,
|
|
39
|
+
outputFiles: result.outputFiles ?? [],
|
|
40
|
+
error: result.error ?? null,
|
|
41
|
+
});
|
|
42
|
+
if (result.ok) {
|
|
43
|
+
succeeded += 1;
|
|
44
|
+
outputFiles.push(...(result.outputFiles ?? []));
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
failed += 1;
|
|
48
|
+
if (result.error)
|
|
49
|
+
warnings.push(`Job ${index} failed: ${result.error}`);
|
|
50
|
+
if (!continueOnError)
|
|
51
|
+
stopped = true;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const skipped = results.filter((entry) => entry.skipped).length;
|
|
55
|
+
return {
|
|
56
|
+
ok: failed === 0,
|
|
57
|
+
data: {
|
|
58
|
+
total: jobs.length,
|
|
59
|
+
succeeded,
|
|
60
|
+
failed,
|
|
61
|
+
skipped,
|
|
62
|
+
continueOnError,
|
|
63
|
+
results,
|
|
64
|
+
},
|
|
65
|
+
outputFiles,
|
|
66
|
+
warnings,
|
|
67
|
+
keyshotStdoutTail: "",
|
|
68
|
+
error: failed === 0 ? null : `${failed} of ${jobs.length} render job(s) failed.`,
|
|
69
|
+
};
|
|
70
|
+
}
|
package/dist/runner.js
CHANGED
|
@@ -42,46 +42,59 @@ async function runKeyShot(config, request) {
|
|
|
42
42
|
argsPath,
|
|
43
43
|
resultPath,
|
|
44
44
|
];
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
45
|
+
try {
|
|
46
|
+
const processResult = await spawnWithTimeout(config.keyshotHeadlessExe, args, config.keyshotTimeoutMs);
|
|
47
|
+
const stdoutTail = tail(processResult.stdout, 6000);
|
|
48
|
+
const stderrTail = tail(processResult.stderr, 6000);
|
|
49
|
+
let parsed = null;
|
|
50
|
+
if (await exists(resultPath)) {
|
|
51
|
+
try {
|
|
52
|
+
parsed = JSON.parse(await fs.readFile(resultPath, "utf8"));
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
return localFailure(`Could not parse KeyShot result JSON: ${errorMessage(error)}`, {
|
|
56
|
+
keyshotStdoutTail: stdoutTail,
|
|
57
|
+
warnings: stderrTail ? [`stderr: ${stderrTail}`] : [],
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (processResult.timedOut) {
|
|
62
|
+
return localFailure(`KeyShot timed out after ${config.keyshotTimeoutMs}ms`, {
|
|
63
|
+
data: parsed?.data ?? null,
|
|
64
|
+
outputFiles: parsed?.outputFiles ?? [],
|
|
65
|
+
warnings: [...(parsed?.warnings ?? []), ...(stderrTail ? [`stderr: ${stderrTail}`] : [])],
|
|
66
|
+
keyshotStdoutTail: stdoutTail,
|
|
67
|
+
});
|
|
52
68
|
}
|
|
53
|
-
|
|
54
|
-
return localFailure(`
|
|
69
|
+
if (!parsed) {
|
|
70
|
+
return localFailure(`KeyShot did not produce a result file. Exit code: ${processResult.exitCode ?? "unknown"}`, {
|
|
55
71
|
keyshotStdoutTail: stdoutTail,
|
|
56
72
|
warnings: stderrTail ? [`stderr: ${stderrTail}`] : [],
|
|
57
73
|
});
|
|
58
74
|
}
|
|
75
|
+
parsed.keyshotStdoutTail = stdoutTail || parsed.keyshotStdoutTail || "";
|
|
76
|
+
if (stderrTail)
|
|
77
|
+
parsed.warnings = [...parsed.warnings, `stderr: ${stderrTail}`];
|
|
78
|
+
if (processResult.exitCode !== 0 && parsed.ok) {
|
|
79
|
+
return {
|
|
80
|
+
...parsed,
|
|
81
|
+
ok: false,
|
|
82
|
+
error: `KeyShot exited with code ${processResult.exitCode}`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return parsed;
|
|
59
86
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
outputFiles: parsed?.outputFiles ?? [],
|
|
64
|
-
warnings: [...(parsed?.warnings ?? []), ...(stderrTail ? [`stderr: ${stderrTail}`] : [])],
|
|
65
|
-
keyshotStdoutTail: stdoutTail,
|
|
66
|
-
});
|
|
67
|
-
}
|
|
68
|
-
if (!parsed) {
|
|
69
|
-
return localFailure(`KeyShot did not produce a result file. Exit code: ${processResult.exitCode ?? "unknown"}`, {
|
|
70
|
-
keyshotStdoutTail: stdoutTail,
|
|
71
|
-
warnings: stderrTail ? [`stderr: ${stderrTail}`] : [],
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
parsed.keyshotStdoutTail = stdoutTail || parsed.keyshotStdoutTail || "";
|
|
75
|
-
if (stderrTail)
|
|
76
|
-
parsed.warnings = [...parsed.warnings, `stderr: ${stderrTail}`];
|
|
77
|
-
if (processResult.exitCode !== 0 && parsed.ok) {
|
|
78
|
-
return {
|
|
79
|
-
...parsed,
|
|
80
|
-
ok: false,
|
|
81
|
-
error: `KeyShot exited with code ${processResult.exitCode}`,
|
|
82
|
-
};
|
|
87
|
+
finally {
|
|
88
|
+
// Best-effort cleanup so work/tmp does not accumulate args/result files.
|
|
89
|
+
await cleanupTmp([argsPath, resultPath]).catch(() => undefined);
|
|
83
90
|
}
|
|
84
|
-
|
|
91
|
+
}
|
|
92
|
+
async function cleanupTmp(paths) {
|
|
93
|
+
await Promise.all(paths.map(async (filePath) => {
|
|
94
|
+
if (await exists(filePath)) {
|
|
95
|
+
await fs.rm(filePath, { force: true });
|
|
96
|
+
}
|
|
97
|
+
}));
|
|
85
98
|
}
|
|
86
99
|
function spawnWithTimeout(command, args, timeoutMs) {
|
|
87
100
|
return new Promise((resolve) => {
|
package/dist/schemas.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
const optionalPath = z.string().min(1).optional();
|
|
3
|
+
const imageFormat = z.enum(["png", "jpg", "jpeg", "tif", "tiff", "exr"]);
|
|
3
4
|
export const scenePathSchema = z.object({
|
|
4
5
|
scenePath: z.string().min(1),
|
|
5
6
|
});
|
|
7
|
+
export const listCamerasSchema = z.object({
|
|
8
|
+
scenePath: z.string().min(1),
|
|
9
|
+
});
|
|
6
10
|
export const renderSchema = z.object({
|
|
7
11
|
scenePath: z.string().min(1),
|
|
8
12
|
outputPath: optionalPath,
|
|
@@ -13,6 +17,10 @@ export const renderSchema = z.object({
|
|
|
13
17
|
camera: z.string().min(1).optional(),
|
|
14
18
|
format: z.enum(["png", "jpg", "jpeg", "tif", "tiff", "exr"]).optional(),
|
|
15
19
|
});
|
|
20
|
+
export const renderInputSchema = renderSchema.refine((value) => !(value.samples !== undefined && value.maxTimeSeconds !== undefined), {
|
|
21
|
+
message: "Choose either samples or maxTimeSeconds, not both.",
|
|
22
|
+
path: ["maxTimeSeconds"],
|
|
23
|
+
});
|
|
16
24
|
export const batchRenderSchema = z.object({
|
|
17
25
|
scenePath: z.string().min(1),
|
|
18
26
|
outputDir: z.string().min(1),
|
|
@@ -24,6 +32,10 @@ export const batchRenderSchema = z.object({
|
|
|
24
32
|
format: z.enum(["png", "jpg", "jpeg", "tif", "tiff", "exr"]).optional(),
|
|
25
33
|
overwrite: z.boolean().optional(),
|
|
26
34
|
});
|
|
35
|
+
export const batchRenderInputSchema = batchRenderSchema.refine((value) => !(value.samples !== undefined && value.maxTimeSeconds !== undefined), {
|
|
36
|
+
message: "Choose either samples or maxTimeSeconds, not both.",
|
|
37
|
+
path: ["maxTimeSeconds"],
|
|
38
|
+
});
|
|
27
39
|
export const importModelSchema = z.object({
|
|
28
40
|
modelPath: z.string().min(1),
|
|
29
41
|
baseScenePath: optionalPath,
|
|
@@ -63,3 +75,36 @@ export const saveSceneSchema = z.object({
|
|
|
63
75
|
scenePath: z.string().min(1),
|
|
64
76
|
outputScenePath: z.string().min(1),
|
|
65
77
|
});
|
|
78
|
+
// --- Render queue ---
|
|
79
|
+
export const renderJobSchema = z.object({
|
|
80
|
+
scenePath: z.string().min(1),
|
|
81
|
+
outputPath: optionalPath,
|
|
82
|
+
camera: z.string().min(1).optional(),
|
|
83
|
+
width: z.number().int().positive().optional(),
|
|
84
|
+
height: z.number().int().positive().optional(),
|
|
85
|
+
samples: z.number().int().positive().optional(),
|
|
86
|
+
maxTimeSeconds: z.number().positive().optional(),
|
|
87
|
+
format: imageFormat.optional(),
|
|
88
|
+
});
|
|
89
|
+
export const renderJobInputSchema = renderJobSchema.refine((value) => !(value.samples !== undefined && value.maxTimeSeconds !== undefined), {
|
|
90
|
+
message: "Choose either samples or maxTimeSeconds, not both.",
|
|
91
|
+
path: ["maxTimeSeconds"],
|
|
92
|
+
});
|
|
93
|
+
export const renderQueueSchema = z.object({
|
|
94
|
+
jobs: z.array(renderJobSchema).min(1),
|
|
95
|
+
continueOnError: z.boolean().optional(),
|
|
96
|
+
});
|
|
97
|
+
export const renderQueueInputSchema = z.object({
|
|
98
|
+
jobs: z.array(renderJobInputSchema).min(1),
|
|
99
|
+
continueOnError: z.boolean().optional(),
|
|
100
|
+
});
|
|
101
|
+
// --- Material preset library ---
|
|
102
|
+
export const listMaterialPresetsSchema = z.object({});
|
|
103
|
+
export const applyMaterialPresetInputSchema = z.object({
|
|
104
|
+
scenePath: z.string().min(1),
|
|
105
|
+
presetName: z.string().min(1),
|
|
106
|
+
objectName: z.string().min(1).optional(),
|
|
107
|
+
objectPath: z.string().min(1).optional(),
|
|
108
|
+
outputScenePath: z.string().min(1),
|
|
109
|
+
});
|
|
110
|
+
export const applyMaterialPresetSchema = applyMaterialPresetInputSchema.refine((value) => value.objectName || value.objectPath, { message: "Provide objectName or objectPath." });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keyshot-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Local and portable MCP server for KeyShot headless scripting.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"homepage": "https://github.com/truman-t3/keyshot-mcp#readme",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"prepare": "tsc -p tsconfig.json",
|
|
20
20
|
"start": "node ./dist/index.js",
|
|
21
21
|
"check": "tsc -p tsconfig.json --noEmit",
|
|
22
|
+
"test": "vitest run",
|
|
22
23
|
"status": "node ./dist/cli/status.js"
|
|
23
24
|
},
|
|
24
25
|
"keywords": [
|
|
@@ -33,6 +34,7 @@
|
|
|
33
34
|
"dist",
|
|
34
35
|
"scripts/keyshot_bridge.py",
|
|
35
36
|
"examples",
|
|
37
|
+
"presets",
|
|
36
38
|
"README.md",
|
|
37
39
|
"CONTRIBUTING.md",
|
|
38
40
|
"SECURITY.md",
|
|
@@ -48,6 +50,7 @@
|
|
|
48
50
|
},
|
|
49
51
|
"devDependencies": {
|
|
50
52
|
"@types/node": "^22.17.2",
|
|
51
|
-
"typescript": "^5.9.2"
|
|
53
|
+
"typescript": "^5.9.2",
|
|
54
|
+
"vitest": "^3.2.4"
|
|
52
55
|
}
|
|
53
56
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"Brushed Steel": {
|
|
3
|
+
"materialName": "Steel Brushed",
|
|
4
|
+
"description": "Default brushed stainless steel look for metal parts."
|
|
5
|
+
},
|
|
6
|
+
"Matte Black Plastic": {
|
|
7
|
+
"materialName": "Plastic Matte Black",
|
|
8
|
+
"description": "Soft-touch matte black plastic for housings and enclosures."
|
|
9
|
+
},
|
|
10
|
+
"Clear Glass": {
|
|
11
|
+
"materialName": "Glass Clear",
|
|
12
|
+
"description": "Transparent glass for covers and lenses."
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -31,6 +31,8 @@ def main():
|
|
|
31
31
|
data = status()
|
|
32
32
|
elif operation == "inspect_scene":
|
|
33
33
|
data = inspect_scene()
|
|
34
|
+
elif operation == "list_cameras":
|
|
35
|
+
data = list_cameras()
|
|
34
36
|
elif operation == "render":
|
|
35
37
|
data = render(payload, output_files, warnings)
|
|
36
38
|
elif operation == "batch_render":
|
|
@@ -96,6 +98,25 @@ def inspect_scene():
|
|
|
96
98
|
}
|
|
97
99
|
|
|
98
100
|
|
|
101
|
+
def list_cameras():
|
|
102
|
+
raw = safe_list_call("getCameras")
|
|
103
|
+
names = []
|
|
104
|
+
for camera in raw:
|
|
105
|
+
if isinstance(camera, str):
|
|
106
|
+
name = camera
|
|
107
|
+
else:
|
|
108
|
+
name = first_success(
|
|
109
|
+
lambda camera=camera: camera.getName(),
|
|
110
|
+
lambda camera=camera: camera.name(),
|
|
111
|
+
lambda camera=camera: camera.name,
|
|
112
|
+
default=None,
|
|
113
|
+
)
|
|
114
|
+
if name is None:
|
|
115
|
+
name = repr(camera)
|
|
116
|
+
names.append(name)
|
|
117
|
+
return {"cameras": names, "count": len(names)}
|
|
118
|
+
|
|
119
|
+
|
|
99
120
|
def render(payload, output_files, warnings):
|
|
100
121
|
output_path = payload.get("outputPath") or default_output_path(payload, "render", payload.get("format") or "png")
|
|
101
122
|
ensure_parent(output_path)
|
|
@@ -116,16 +137,35 @@ def render(payload, output_files, warnings):
|
|
|
116
137
|
samples = payload.get("samples")
|
|
117
138
|
max_time = payload.get("maxTimeSeconds")
|
|
118
139
|
|
|
119
|
-
|
|
120
|
-
"render
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
140
|
+
if samples is not None and max_time is not None:
|
|
141
|
+
raise RuntimeError("samples and maxTimeSeconds cannot be used together; choose one render mode.")
|
|
142
|
+
|
|
143
|
+
render_options = build_render_options(samples, max_time)
|
|
144
|
+
|
|
145
|
+
if render_options is None:
|
|
146
|
+
call_variants(
|
|
147
|
+
"render image",
|
|
148
|
+
lambda: lux.renderImage(output_path, width, height),
|
|
149
|
+
lambda: lux.renderImage(output_path),
|
|
150
|
+
)
|
|
151
|
+
else:
|
|
152
|
+
# Current KeyShot versions accept a RenderOptions object. Keep positional
|
|
153
|
+
# and keyword variants for older headless scripting builds.
|
|
154
|
+
call_variants(
|
|
155
|
+
"render image with options",
|
|
156
|
+
lambda: lux.renderImage(output_path, width, height, opts=render_options),
|
|
157
|
+
lambda: lux.renderImage(output_path, width, height, render_options),
|
|
158
|
+
lambda: lux.renderImage(output_path, opts=render_options),
|
|
159
|
+
)
|
|
126
160
|
|
|
127
161
|
output_files.append(output_path)
|
|
128
|
-
return {
|
|
162
|
+
return {
|
|
163
|
+
"rendered": output_path,
|
|
164
|
+
"width": width,
|
|
165
|
+
"height": height,
|
|
166
|
+
"samples": samples,
|
|
167
|
+
"maxTimeSeconds": max_time,
|
|
168
|
+
}
|
|
129
169
|
|
|
130
170
|
|
|
131
171
|
def batch_render(payload, output_files, warnings):
|
|
@@ -177,11 +217,19 @@ def batch_render(payload, output_files, warnings):
|
|
|
177
217
|
def import_model(payload, output_files, warnings):
|
|
178
218
|
model_path = payload.get("modelPath")
|
|
179
219
|
output_scene_path = payload.get("outputScenePath")
|
|
220
|
+
base_scene_path = payload.get("baseScenePath")
|
|
180
221
|
if not model_path or not os.path.exists(model_path):
|
|
181
222
|
raise RuntimeError("Model file not found: %s" % model_path)
|
|
182
223
|
if not output_scene_path:
|
|
183
224
|
raise RuntimeError("outputScenePath is required")
|
|
184
225
|
|
|
226
|
+
if base_scene_path:
|
|
227
|
+
if not os.path.exists(base_scene_path):
|
|
228
|
+
raise RuntimeError("Base scene file not found: %s" % base_scene_path)
|
|
229
|
+
open_scene(base_scene_path, warnings)
|
|
230
|
+
else:
|
|
231
|
+
new_scene(warnings)
|
|
232
|
+
|
|
185
233
|
call_variants(
|
|
186
234
|
"import model",
|
|
187
235
|
lambda: lux.importFile(model_path),
|
|
@@ -189,7 +237,33 @@ def import_model(payload, output_files, warnings):
|
|
|
189
237
|
)
|
|
190
238
|
save_to(output_scene_path)
|
|
191
239
|
output_files.append(output_scene_path)
|
|
192
|
-
return {
|
|
240
|
+
return {
|
|
241
|
+
"importedModel": model_path,
|
|
242
|
+
"baseScene": base_scene_path,
|
|
243
|
+
"savedScene": output_scene_path,
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def open_scene(scene_path, warnings):
|
|
248
|
+
call_variants(
|
|
249
|
+
"open base scene",
|
|
250
|
+
lambda: lux.openScene(scene_path),
|
|
251
|
+
lambda: lux.openFile(scene_path),
|
|
252
|
+
lambda: lux.loadScene(scene_path),
|
|
253
|
+
lambda: lux.openProject(scene_path),
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def new_scene(warnings):
|
|
258
|
+
try:
|
|
259
|
+
call_variants(
|
|
260
|
+
"create new scene",
|
|
261
|
+
lambda: lux.newScene(),
|
|
262
|
+
lambda: lux.createScene(),
|
|
263
|
+
lambda: lux.newProject(),
|
|
264
|
+
)
|
|
265
|
+
except RuntimeError:
|
|
266
|
+
warnings.append("Started import into the current KeyShot scene (could not create a new scene).")
|
|
193
267
|
|
|
194
268
|
|
|
195
269
|
def apply_material(payload, output_files, warnings):
|
|
@@ -225,34 +299,45 @@ def set_camera(payload, output_files, warnings):
|
|
|
225
299
|
if position is None or look_at is None:
|
|
226
300
|
raise RuntimeError("position and lookAt are required")
|
|
227
301
|
|
|
228
|
-
camera =
|
|
302
|
+
camera = first_camera_object(
|
|
229
303
|
lambda: lux.getCamera(camera_name),
|
|
230
304
|
lambda: lux.newCamera(camera_name),
|
|
231
305
|
lambda: lux.createCamera(camera_name),
|
|
232
|
-
default=None,
|
|
233
306
|
)
|
|
234
307
|
|
|
235
308
|
if camera is None and hasattr(lux, "saveCamera"):
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
lambda: camera.
|
|
247
|
-
lambda:
|
|
248
|
-
lambda:
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
309
|
+
try:
|
|
310
|
+
camera = first_camera_object(
|
|
311
|
+
lambda: lux.saveCamera(camera_name),
|
|
312
|
+
lambda: lux.saveCamera(),
|
|
313
|
+
)
|
|
314
|
+
except RuntimeError:
|
|
315
|
+
camera = None
|
|
316
|
+
|
|
317
|
+
if camera is not None:
|
|
318
|
+
# Object-level API is available: drive the camera object directly.
|
|
319
|
+
call_variants("set camera position", lambda: camera.setPosition(tuple(position)))
|
|
320
|
+
call_variants("set camera look-at", lambda: camera.setLookAt(tuple(look_at)))
|
|
321
|
+
call_variants("set camera up", lambda: camera.setUp(tuple(up)))
|
|
322
|
+
else:
|
|
323
|
+
# No camera object could be obtained: fall back to the lux-level setters,
|
|
324
|
+
# which address the camera by name. This path never dereferences a None
|
|
325
|
+
# camera object, so it cannot crash when camera creation is unsupported.
|
|
326
|
+
call_variants(
|
|
327
|
+
"set camera position",
|
|
328
|
+
lambda: lux.setCameraPosition(camera_name, tuple(position)),
|
|
329
|
+
lambda: lux.setCameraPosition(tuple(position)),
|
|
330
|
+
)
|
|
331
|
+
call_variants(
|
|
332
|
+
"set camera look-at",
|
|
333
|
+
lambda: lux.setCameraLookAt(camera_name, tuple(look_at)),
|
|
334
|
+
lambda: lux.setCameraLookAt(tuple(look_at)),
|
|
335
|
+
)
|
|
336
|
+
call_variants(
|
|
337
|
+
"set camera up",
|
|
338
|
+
lambda: lux.setCameraUp(camera_name, tuple(up)),
|
|
339
|
+
lambda: lux.setCameraUp(tuple(up)),
|
|
340
|
+
)
|
|
256
341
|
|
|
257
342
|
save_to(output_scene_path)
|
|
258
343
|
output_files.append(output_scene_path)
|
|
@@ -386,13 +471,13 @@ def save_to(output_scene_path):
|
|
|
386
471
|
|
|
387
472
|
|
|
388
473
|
def call_variants(label, *callbacks):
|
|
474
|
+
"""Try each callback in order. The first one that does not raise wins, even
|
|
475
|
+
if it returns None (KeyShot setters typically return nothing). Only if every
|
|
476
|
+
callback raises do we report the combined errors."""
|
|
389
477
|
errors = []
|
|
390
478
|
for callback in callbacks:
|
|
391
479
|
try:
|
|
392
|
-
|
|
393
|
-
if value is not None:
|
|
394
|
-
return value
|
|
395
|
-
return value
|
|
480
|
+
return callback()
|
|
396
481
|
except Exception as exc:
|
|
397
482
|
errors.append(str(exc))
|
|
398
483
|
raise RuntimeError("%s is unsupported or failed: %s" % (label, " | ".join(errors)))
|
|
@@ -407,6 +492,62 @@ def first_success(*callbacks, default=None):
|
|
|
407
492
|
return default
|
|
408
493
|
|
|
409
494
|
|
|
495
|
+
def first_non_none(*callbacks, default=None):
|
|
496
|
+
for callback in callbacks:
|
|
497
|
+
try:
|
|
498
|
+
value = callback()
|
|
499
|
+
if value is not None:
|
|
500
|
+
return value
|
|
501
|
+
except Exception:
|
|
502
|
+
pass
|
|
503
|
+
return default
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def first_camera_object(*callbacks):
|
|
507
|
+
for callback in callbacks:
|
|
508
|
+
try:
|
|
509
|
+
value = callback()
|
|
510
|
+
if is_camera_object(value):
|
|
511
|
+
return value
|
|
512
|
+
except Exception:
|
|
513
|
+
pass
|
|
514
|
+
return None
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def is_camera_object(value):
|
|
518
|
+
if value is None or value is False or value is True:
|
|
519
|
+
return False
|
|
520
|
+
return any(hasattr(value, method) for method in ("setPosition", "setLookAt", "setUp"))
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def build_render_options(samples, max_time):
|
|
524
|
+
if samples is None and max_time is None:
|
|
525
|
+
return None
|
|
526
|
+
|
|
527
|
+
get_options = getattr(lux, "getRenderOptions", None)
|
|
528
|
+
if get_options is None:
|
|
529
|
+
raise RuntimeError("KeyShot lux.getRenderOptions is not available for advanced render settings.")
|
|
530
|
+
|
|
531
|
+
options = get_options()
|
|
532
|
+
if options is None:
|
|
533
|
+
raise RuntimeError("KeyShot returned no RenderOptions object for advanced render settings.")
|
|
534
|
+
|
|
535
|
+
if samples is not None:
|
|
536
|
+
call_variants(
|
|
537
|
+
"set render samples",
|
|
538
|
+
lambda: options.setMaxSamplesRendering(samples),
|
|
539
|
+
lambda: options.setAdvancedRendering(samples),
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
if max_time is not None:
|
|
543
|
+
call_variants(
|
|
544
|
+
"set render max time",
|
|
545
|
+
lambda: options.setMaxTimeRendering(max_time),
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
return options
|
|
549
|
+
|
|
550
|
+
|
|
410
551
|
def serialize_value(value):
|
|
411
552
|
if value is None or isinstance(value, (str, int, float, bool)):
|
|
412
553
|
return value
|