keyshot-mcp 0.7.0 → 0.8.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 +117 -39
- package/dist/index.js +19 -3
- package/dist/product-render.js +83 -0
- package/dist/runner.js +16 -0
- package/dist/schemas.js +88 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/scripts/keyshot-smoke.mjs +47 -11
- package/scripts/keyshot_bridge.py +207 -26
- package/server.json +3 -3
- package/assets/star-history-dark.svg +0 -25
- package/assets/star-history-light.svg +0 -25
package/README.md
CHANGED
|
@@ -28,6 +28,7 @@ the local computer.
|
|
|
28
28
|
- Apply materials directly or through a local material preset library.
|
|
29
29
|
- Select environments, change brightness, and rotate the active environment.
|
|
30
30
|
- Save edited scenes to a controlled output directory.
|
|
31
|
+
- Prepare and render a product from a model or existing scene in one tool call.
|
|
31
32
|
|
|
32
33
|
### Requirements and compatibility
|
|
33
34
|
|
|
@@ -40,7 +41,7 @@ the local computer.
|
|
|
40
41
|
|
|
41
42
|
### Install
|
|
42
43
|
|
|
43
|
-
The current release is `0.
|
|
44
|
+
The current release is `0.8.0`.
|
|
44
45
|
|
|
45
46
|
#### Option 1: run with npx
|
|
46
47
|
|
|
@@ -51,7 +52,7 @@ This requires no global npm installation:
|
|
|
51
52
|
"mcpServers": {
|
|
52
53
|
"keyshot": {
|
|
53
54
|
"command": "npx",
|
|
54
|
-
"args": ["-y", "keyshot-mcp@0.
|
|
55
|
+
"args": ["-y", "keyshot-mcp@0.8.0"],
|
|
55
56
|
"env": {
|
|
56
57
|
"KEYSHOT_HEADLESS_EXE": "C:/Program Files/KeyShot Studio/bin/keyshot_headless.exe"
|
|
57
58
|
}
|
|
@@ -63,7 +64,7 @@ This requires no global npm installation:
|
|
|
63
64
|
#### Option 2: install globally
|
|
64
65
|
|
|
65
66
|
```bash
|
|
66
|
-
npm install -g keyshot-mcp@0.
|
|
67
|
+
npm install -g keyshot-mcp@0.8.0
|
|
67
68
|
```
|
|
68
69
|
|
|
69
70
|
```json
|
|
@@ -112,14 +113,14 @@ The following prompt can be pasted into a coding agent that is allowed to edit
|
|
|
112
113
|
the MCP client configuration:
|
|
113
114
|
|
|
114
115
|
```text
|
|
115
|
-
Install KeyShot MCP 0.
|
|
116
|
+
Install KeyShot MCP 0.8.0 and configure it in my MCP client.
|
|
116
117
|
|
|
117
118
|
1. Find the local KeyShot headless executable.
|
|
118
119
|
2. Add an MCP server named "keyshot" that runs:
|
|
119
|
-
npx -y keyshot-mcp@0.
|
|
120
|
+
npx -y keyshot-mcp@0.8.0
|
|
120
121
|
3. Set KEYSHOT_HEADLESS_EXE to the executable path.
|
|
121
122
|
4. Keep KEYSHOT_ALLOW_EXTERNAL_OUTPUTS disabled.
|
|
122
|
-
5. Restart or reload the MCP client, then
|
|
123
|
+
5. Restart or reload the MCP client, call keyshot_status, then use keyshot_product_render for a one-click product render.
|
|
123
124
|
6. Do not upload or publish any KeyShot scenes, models, renders, or license data.
|
|
124
125
|
```
|
|
125
126
|
|
|
@@ -144,6 +145,7 @@ scene, model, material, and environment paths may be located elsewhere.
|
|
|
144
145
|
| Tool | Purpose |
|
|
145
146
|
| --- | --- |
|
|
146
147
|
| `keyshot_status` | Verify that KeyShot headless can start and report its version. |
|
|
148
|
+
| `keyshot_product_render` | Prepare, save, and render a model or existing scene in one headless process. |
|
|
147
149
|
| `keyshot_inspect_scene` | Inspect objects, cameras, materials, model sets, and references. |
|
|
148
150
|
| `keyshot_list_cameras` | List available camera names. |
|
|
149
151
|
| `keyshot_render` | Render one image. |
|
|
@@ -160,9 +162,48 @@ scene, model, material, and environment paths may be located elsewhere.
|
|
|
160
162
|
| `keyshot_set_environment` | Select or adjust an environment, brightness, or rotation. |
|
|
161
163
|
| `keyshot_save_scene` | Save a scene to a new output path. |
|
|
162
164
|
|
|
163
|
-
The server
|
|
165
|
+
The server exposes 17 tools, a `keyshot_product_render` MCP prompt, and a
|
|
164
166
|
`keyshot://workflow` resource.
|
|
165
167
|
|
|
168
|
+
### One-click product render
|
|
169
|
+
|
|
170
|
+
In an MCP client, a designer can use a natural-language request:
|
|
171
|
+
|
|
172
|
+
```text
|
|
173
|
+
Turn C:\models\speaker.obj into a product render. Center it, place it on the
|
|
174
|
+
ground, use the Isometric camera preset, save the KeyShot scene, and render a
|
|
175
|
+
1600 x 1200 PNG with 128 samples.
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
The `keyshot_product_render` tool accepts either `modelPath` or `scenePath`. New
|
|
179
|
+
models default to centered and grounded geometry with a `Product Hero`
|
|
180
|
+
isometric camera. Existing scenes keep their current camera, materials, and
|
|
181
|
+
environment unless explicit changes are requested.
|
|
182
|
+
|
|
183
|
+
```json
|
|
184
|
+
{
|
|
185
|
+
"modelPath": "C:/models/speaker.obj",
|
|
186
|
+
"outputScenePath": "speaker-product.bip",
|
|
187
|
+
"outputPath": "speaker-product.png",
|
|
188
|
+
"materialAssignments": [
|
|
189
|
+
{ "objectName": "Body", "presetName": "Brushed Steel" }
|
|
190
|
+
],
|
|
191
|
+
"cameraPresetName": "Isometric",
|
|
192
|
+
"focalLength": 55,
|
|
193
|
+
"brightness": 1.2,
|
|
194
|
+
"rotation": 45,
|
|
195
|
+
"width": 1600,
|
|
196
|
+
"height": 1200,
|
|
197
|
+
"samples": 128
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Use `renderMode: "allCameras"` with `outputDir` to render every named camera.
|
|
202
|
+
Generated names are derived from the source filename when output paths are
|
|
203
|
+
omitted. Existing files are protected by default; set `overwrite: true` only
|
|
204
|
+
when replacement is intentional. Material assignments always require an
|
|
205
|
+
explicit object and never overwrite the entire model implicitly.
|
|
206
|
+
|
|
166
207
|
### Product composition examples
|
|
167
208
|
|
|
168
209
|
Import a model and prepare its initial composition:
|
|
@@ -222,7 +263,8 @@ them.
|
|
|
222
263
|
The repository includes a smoke test built from generated cube geometry in
|
|
223
264
|
`examples/demo`. It verifies startup, import composition, scene inspection,
|
|
224
265
|
camera presets, focal length, field of view, camera distance, environment
|
|
225
|
-
rotation, scene saving, camera discovery,
|
|
266
|
+
rotation, scene saving, camera discovery, one-click model rendering, one-click
|
|
267
|
+
existing-scene rendering, and real PNG output:
|
|
226
268
|
|
|
227
269
|
```bash
|
|
228
270
|
npm run smoke:keyshot
|
|
@@ -254,18 +296,6 @@ Linux.
|
|
|
254
296
|
- Add depth-of-field and additional lens controls when stable headless APIs are
|
|
255
297
|
available.
|
|
256
298
|
|
|
257
|
-
### Star history
|
|
258
|
-
|
|
259
|
-
<picture>
|
|
260
|
-
<source media="(prefers-color-scheme: dark)" srcset="assets/star-history-dark.svg">
|
|
261
|
-
<source media="(prefers-color-scheme: light)" srcset="assets/star-history-light.svg">
|
|
262
|
-
<img alt="GitHub star history for truman-t3/keyshot-mcp" src="assets/star-history-light.svg">
|
|
263
|
-
</picture>
|
|
264
|
-
|
|
265
|
-
The chart uses the actual `starred_at` date for each GitHub star and displays a
|
|
266
|
-
cumulative step line. A weekly GitHub Action updates it only when detailed
|
|
267
|
-
stargazer dates are available.
|
|
268
|
-
|
|
269
299
|
### License and security
|
|
270
300
|
|
|
271
301
|
Released under the [MIT License](LICENSE). See [SECURITY.md](SECURITY.md) for
|
|
@@ -273,6 +303,18 @@ security guidance and [CONTRIBUTING.md](CONTRIBUTING.md) for development notes.
|
|
|
273
303
|
Do not commit KeyShot licenses, private scenes, customer assets, or unpublished
|
|
274
304
|
renders.
|
|
275
305
|
|
|
306
|
+
### Trademark and project status
|
|
307
|
+
|
|
308
|
+
KeyShot is a trademark of KeyShot ApS and/or KeyShot Inc. This is an independent,
|
|
309
|
+
open-source community project and is not affiliated with, endorsed by, or
|
|
310
|
+
sponsored by KeyShot.
|
|
311
|
+
|
|
312
|
+
This project provides only an MCP integration. It does not include KeyShot
|
|
313
|
+
Studio, KeyShot assets, or a KeyShot license. Users must install and license
|
|
314
|
+
KeyShot Studio separately and comply with the applicable KeyShot terms. Do not
|
|
315
|
+
use this project to bypass licensing, share credentials, or redistribute KeyShot
|
|
316
|
+
software or proprietary resources.
|
|
317
|
+
|
|
276
318
|
---
|
|
277
319
|
|
|
278
320
|
## 中文
|
|
@@ -291,6 +333,7 @@ headless 脚本让兼容 MCP 的 AI Agent 检查、编辑和渲染 KeyShot 场
|
|
|
291
333
|
- 直接应用材质,或使用本地材质预设库。
|
|
292
334
|
- 选择环境、调整亮度并旋转当前环境。
|
|
293
335
|
- 将修改后的场景保存到受控输出目录。
|
|
336
|
+
- 通过一次工具调用完成模型或现有场景的产品构图与渲染。
|
|
294
337
|
|
|
295
338
|
### 运行要求与兼容性
|
|
296
339
|
|
|
@@ -302,7 +345,7 @@ headless 脚本让兼容 MCP 的 AI Agent 检查、编辑和渲染 KeyShot 场
|
|
|
302
345
|
|
|
303
346
|
### 安装
|
|
304
347
|
|
|
305
|
-
当前版本为 `0.
|
|
348
|
+
当前版本为 `0.8.0`。
|
|
306
349
|
|
|
307
350
|
#### 方式一:使用 npx
|
|
308
351
|
|
|
@@ -313,7 +356,7 @@ headless 脚本让兼容 MCP 的 AI Agent 检查、编辑和渲染 KeyShot 场
|
|
|
313
356
|
"mcpServers": {
|
|
314
357
|
"keyshot": {
|
|
315
358
|
"command": "npx",
|
|
316
|
-
"args": ["-y", "keyshot-mcp@0.
|
|
359
|
+
"args": ["-y", "keyshot-mcp@0.8.0"],
|
|
317
360
|
"env": {
|
|
318
361
|
"KEYSHOT_HEADLESS_EXE": "C:/Program Files/KeyShot Studio/bin/keyshot_headless.exe"
|
|
319
362
|
}
|
|
@@ -325,7 +368,7 @@ headless 脚本让兼容 MCP 的 AI Agent 检查、编辑和渲染 KeyShot 场
|
|
|
325
368
|
#### 方式二:全局安装
|
|
326
369
|
|
|
327
370
|
```bash
|
|
328
|
-
npm install -g keyshot-mcp@0.
|
|
371
|
+
npm install -g keyshot-mcp@0.8.0
|
|
329
372
|
```
|
|
330
373
|
|
|
331
374
|
```json
|
|
@@ -373,14 +416,14 @@ pnpm build
|
|
|
373
416
|
下面的提示词适用于有权限修改 MCP 客户端配置的编程 Agent:
|
|
374
417
|
|
|
375
418
|
```text
|
|
376
|
-
请帮我安装 KeyShot MCP 0.
|
|
419
|
+
请帮我安装 KeyShot MCP 0.8.0,并添加到我的 MCP 客户端。
|
|
377
420
|
|
|
378
421
|
1. 查找本机 KeyShot headless 可执行文件。
|
|
379
422
|
2. 添加名为 keyshot 的 MCP server,运行:
|
|
380
|
-
npx -y keyshot-mcp@0.
|
|
423
|
+
npx -y keyshot-mcp@0.8.0
|
|
381
424
|
3. 将 KEYSHOT_HEADLESS_EXE 设置为可执行文件路径。
|
|
382
425
|
4. 保持 KEYSHOT_ALLOW_EXTERNAL_OUTPUTS 关闭。
|
|
383
|
-
5. 重启或重新加载 MCP
|
|
426
|
+
5. 重启或重新加载 MCP 客户端,调用 keyshot_status,然后使用 keyshot_product_render 一键完成产品出图。
|
|
384
427
|
6. 不要上传或发布任何 KeyShot 场景、模型、渲染图或许可证数据。
|
|
385
428
|
```
|
|
386
429
|
|
|
@@ -404,6 +447,7 @@ pnpm build
|
|
|
404
447
|
| 工具 | 用途 |
|
|
405
448
|
| --- | --- |
|
|
406
449
|
| `keyshot_status` | 检查 KeyShot headless 是否可启动并读取版本。 |
|
|
450
|
+
| `keyshot_product_render` | 在一个 headless 进程中完成模型或场景准备、保存和渲染。 |
|
|
407
451
|
| `keyshot_inspect_scene` | 检查对象、相机、材质、模型集和外部引用。 |
|
|
408
452
|
| `keyshot_list_cameras` | 列出场景中的相机名称。 |
|
|
409
453
|
| `keyshot_render` | 渲染一张图片。 |
|
|
@@ -420,7 +464,43 @@ pnpm build
|
|
|
420
464
|
| `keyshot_set_environment` | 选择环境并调整亮度或旋转角度。 |
|
|
421
465
|
| `keyshot_save_scene` | 将场景保存到新的输出路径。 |
|
|
422
466
|
|
|
423
|
-
|
|
467
|
+
服务共提供 17 个工具,并提供 `keyshot_product_render` MCP 提示词和
|
|
468
|
+
`keyshot://workflow` 资源。
|
|
469
|
+
|
|
470
|
+
### 一键产品出图
|
|
471
|
+
|
|
472
|
+
设计师可以直接在支持 MCP 的 Agent 中描述需求:
|
|
473
|
+
|
|
474
|
+
```text
|
|
475
|
+
把 C:\models\speaker.obj 做成产品渲染图。自动居中、贴地,使用 Isometric
|
|
476
|
+
相机预设,保存 KeyShot 场景,并用 128 采样渲染一张 1600 x 1200 PNG。
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
`keyshot_product_render` 可以接收 `modelPath` 或 `scenePath`。新模型默认自动居中、
|
|
480
|
+
贴地并创建名为 `Product Hero` 的等轴测相机;已有场景默认保留当前相机、材质和环境,
|
|
481
|
+
只有明确提供参数时才修改。
|
|
482
|
+
|
|
483
|
+
```json
|
|
484
|
+
{
|
|
485
|
+
"modelPath": "C:/models/speaker.obj",
|
|
486
|
+
"outputScenePath": "speaker-product.bip",
|
|
487
|
+
"outputPath": "speaker-product.png",
|
|
488
|
+
"materialAssignments": [
|
|
489
|
+
{ "objectName": "Body", "presetName": "Brushed Steel" }
|
|
490
|
+
],
|
|
491
|
+
"cameraPresetName": "Isometric",
|
|
492
|
+
"focalLength": 55,
|
|
493
|
+
"brightness": 1.2,
|
|
494
|
+
"rotation": 45,
|
|
495
|
+
"width": 1600,
|
|
496
|
+
"height": 1200,
|
|
497
|
+
"samples": 128
|
|
498
|
+
}
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
将 `renderMode` 设为 `allCameras` 并提供 `outputDir`,即可渲染场景中的全部命名相机。
|
|
502
|
+
省略输出路径时会根据源文件名自动生成。默认不会覆盖已有文件,只有明确设置
|
|
503
|
+
`overwrite: true` 才会替换。材质指定必须包含明确的对象,不会隐式覆盖整个模型。
|
|
424
504
|
|
|
425
505
|
### 产品构图示例
|
|
426
506
|
|
|
@@ -474,7 +554,7 @@ pnpm build
|
|
|
474
554
|
|
|
475
555
|
仓库提供基于 `examples/demo` 生成立方体几何体的 smoke test,用于验证 KeyShot
|
|
476
556
|
启动、导入构图、场景检查、相机预设、焦距、视野角、相机距离、环境旋转、场景保存、
|
|
477
|
-
|
|
557
|
+
相机发现、模型一键出图、现有场景一键渲染全部相机和真实 PNG 输出:
|
|
478
558
|
|
|
479
559
|
```bash
|
|
480
560
|
npm run smoke:keyshot
|
|
@@ -503,19 +583,17 @@ CI 在 Windows 和 Ubuntu 上使用 Node.js 20、24 运行。Linux CI 验证 MCP
|
|
|
503
583
|
- 验证 macOS 安装和 headless 行为。
|
|
504
584
|
- 在 headless API 稳定支持后增加景深和更多镜头控制。
|
|
505
585
|
|
|
506
|
-
### 星标趋势
|
|
507
|
-
|
|
508
|
-
<picture>
|
|
509
|
-
<source media="(prefers-color-scheme: dark)" srcset="assets/star-history-dark.svg">
|
|
510
|
-
<source media="(prefers-color-scheme: light)" srcset="assets/star-history-light.svg">
|
|
511
|
-
<img alt="truman-t3/keyshot-mcp 的 GitHub 星标趋势" src="assets/star-history-light.svg">
|
|
512
|
-
</picture>
|
|
513
|
-
|
|
514
|
-
趋势图使用每颗 GitHub Star 的真实 `starred_at` 日期绘制累计阶梯线。GitHub Actions
|
|
515
|
-
每周检查一次,并且只在能够取得逐星日期时更新。
|
|
516
|
-
|
|
517
586
|
### 许可证与安全
|
|
518
587
|
|
|
519
588
|
项目采用 [MIT License](LICENSE)。安全说明见 [SECURITY.md](SECURITY.md),开发说明
|
|
520
589
|
见 [CONTRIBUTING.md](CONTRIBUTING.md)。请勿提交 KeyShot 许可证、私有场景、客户素材
|
|
521
590
|
或未公开渲染图。
|
|
591
|
+
|
|
592
|
+
### 商标与项目性质
|
|
593
|
+
|
|
594
|
+
KeyShot 是 KeyShot ApS 和/或 KeyShot Inc. 的商标。本项目是独立的开源社区项目,
|
|
595
|
+
与 KeyShot 官方无隶属、认可、赞助或其他合作关系。
|
|
596
|
+
|
|
597
|
+
本项目仅提供 MCP 集成功能,不包含 KeyShot Studio、KeyShot 官方素材或 KeyShot
|
|
598
|
+
许可证。用户必须自行安装并合法授权 KeyShot Studio,同时遵守适用的 KeyShot 条款。
|
|
599
|
+
不得使用本项目绕过许可证、共享账号凭据,或重新分发 KeyShot 软件及其专有资源。
|
package/dist/index.js
CHANGED
|
@@ -7,8 +7,9 @@ import { runKeyShotSerialized } from "./runner.js";
|
|
|
7
7
|
import { runRenderQueue } from "./queue.js";
|
|
8
8
|
import { loadMaterialPresets, findMaterialPreset } from "./presets.js";
|
|
9
9
|
import { loadCameraPresets, findCameraPreset } from "./camera-presets.js";
|
|
10
|
+
import { prepareProductRenderRequest } from "./product-render.js";
|
|
10
11
|
import { VERSION } from "./version.js";
|
|
11
|
-
import { applyMaterialSchema, applyMaterialInputSchema, applyMaterialPresetInputSchema, applyMaterialPresetSchema, applyCameraPresetSchema, batchRenderSchema, batchRenderInputSchema, importModelSchema, listCamerasSchema, listMaterialPresetsSchema, listCameraPresetsSchema, renderQueueSchema, renderQueueInputSchema, renderAllCamerasSchema, renderAllCamerasInputSchema, renderSchema, renderInputSchema, saveSceneSchema, scenePathSchema, setCameraInputSchema, setCameraSchema, setEnvironmentSchema, } from "./schemas.js";
|
|
12
|
+
import { applyMaterialSchema, applyMaterialInputSchema, applyMaterialPresetInputSchema, applyMaterialPresetSchema, applyCameraPresetSchema, batchRenderSchema, batchRenderInputSchema, importModelSchema, listCamerasSchema, listMaterialPresetsSchema, listCameraPresetsSchema, productRenderInputSchema, productRenderSchema, renderQueueSchema, renderQueueInputSchema, renderAllCamerasSchema, renderAllCamerasInputSchema, renderSchema, renderInputSchema, saveSceneSchema, scenePathSchema, setCameraInputSchema, setCameraSchema, setEnvironmentSchema, } from "./schemas.js";
|
|
12
13
|
const config = getConfig();
|
|
13
14
|
const server = new McpServer({
|
|
14
15
|
name: "keyshot-mcp",
|
|
@@ -39,6 +40,7 @@ server.registerPrompt("keyshot_product_render", {
|
|
|
39
40
|
title: "Render a KeyShot product scene",
|
|
40
41
|
description: "Create a practical prompt for rendering or batch-rendering a KeyShot product scene.",
|
|
41
42
|
argsSchema: {
|
|
43
|
+
modelPath: scenePathSchema.shape.scenePath.optional(),
|
|
42
44
|
scenePath: scenePathSchema.shape.scenePath.optional(),
|
|
43
45
|
goal: scenePathSchema.shape.scenePath.optional(),
|
|
44
46
|
},
|
|
@@ -50,15 +52,29 @@ server.registerPrompt("keyshot_product_render", {
|
|
|
50
52
|
type: "text",
|
|
51
53
|
text: [
|
|
52
54
|
"Use KeyShot MCP to prepare a product render.",
|
|
53
|
-
args.
|
|
55
|
+
args.modelPath
|
|
56
|
+
? `Model path: ${args.modelPath}`
|
|
57
|
+
: args.scenePath
|
|
58
|
+
? `Scene path: ${args.scenePath}`
|
|
59
|
+
: "Ask me for either a model path or a KeyShot scene path first.",
|
|
54
60
|
args.goal ? `Goal: ${args.goal}` : "Inspect the scene, choose a suitable camera, then render a PNG preview.",
|
|
55
|
-
"
|
|
61
|
+
"Call the keyshot_product_render tool for the complete workflow. Use the lower-level tools only when individual steps need manual control.",
|
|
56
62
|
].join("\n"),
|
|
57
63
|
},
|
|
58
64
|
},
|
|
59
65
|
],
|
|
60
66
|
}));
|
|
61
67
|
server.tool("keyshot_status", "Check KeyShot headless availability and script startup.", {}, async () => toolResponse(await runKeyShotSerialized(config, { operation: "status" })));
|
|
68
|
+
server.tool("keyshot_product_render", "Prepare and render a product from either a model file or an existing KeyShot scene in one headless process.", productRenderInputSchema.shape, async (args) => {
|
|
69
|
+
try {
|
|
70
|
+
const parsed = productRenderSchema.parse(args);
|
|
71
|
+
const request = await prepareProductRenderRequest(config, parsed);
|
|
72
|
+
return toolResponse(await runKeyShotSerialized(config, request));
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
return toolResponse(localFailure(errorMessage(error)));
|
|
76
|
+
}
|
|
77
|
+
});
|
|
62
78
|
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 })));
|
|
63
79
|
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 })));
|
|
64
80
|
server.tool("keyshot_render", "Render a KeyShot scene to an image file.", renderSchema.shape, async (args) => {
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { findCameraPreset, loadCameraPresets } from "./camera-presets.js";
|
|
3
|
+
import { findMaterialPreset, loadMaterialPresets } from "./presets.js";
|
|
4
|
+
export async function prepareProductRenderRequest(config, input) {
|
|
5
|
+
const sourcePath = input.modelPath ?? input.scenePath;
|
|
6
|
+
const stem = safeStem(sourcePath);
|
|
7
|
+
const format = input.format ?? "png";
|
|
8
|
+
const renderMode = input.renderMode ?? "single";
|
|
9
|
+
const request = {
|
|
10
|
+
...input,
|
|
11
|
+
operation: "product_render",
|
|
12
|
+
renderMode,
|
|
13
|
+
format,
|
|
14
|
+
outputScenePath: input.outputScenePath ?? `${stem}-product.bip`,
|
|
15
|
+
overwrite: input.overwrite ?? false,
|
|
16
|
+
continueOnError: input.continueOnError ?? true,
|
|
17
|
+
};
|
|
18
|
+
if (renderMode === "single") {
|
|
19
|
+
request.outputPath = input.outputPath ?? `${stem}-product.${format}`;
|
|
20
|
+
delete request.outputDir;
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
request.outputDir = input.outputDir ?? `${stem}-renders`;
|
|
24
|
+
delete request.outputPath;
|
|
25
|
+
}
|
|
26
|
+
if (input.modelPath) {
|
|
27
|
+
request.centerGeometry = input.centerGeometry ?? true;
|
|
28
|
+
request.snapToGround = input.snapToGround ?? true;
|
|
29
|
+
request.adjustCameraLookAt = input.adjustCameraLookAt ?? true;
|
|
30
|
+
request.adjustEnvironment = input.adjustEnvironment ?? true;
|
|
31
|
+
}
|
|
32
|
+
request.materialAssignments = await resolveMaterialAssignments(config, input.materialAssignments ?? []);
|
|
33
|
+
const presetName = input.cameraPresetName ?? (input.modelPath ? "Isometric" : undefined);
|
|
34
|
+
if (presetName) {
|
|
35
|
+
const presets = await loadCameraPresets(config);
|
|
36
|
+
const preset = findCameraPreset(presets, presetName);
|
|
37
|
+
if (!preset) {
|
|
38
|
+
const available = presets.map((entry) => entry.name).join(", ") || "(none)";
|
|
39
|
+
throw new Error(`Camera preset not found: "${presetName}". Available: ${available}`);
|
|
40
|
+
}
|
|
41
|
+
request.cameraPresetName = preset.name;
|
|
42
|
+
request.cameraName = input.cameraName ?? (input.modelPath ? "Product Hero" : preset.name);
|
|
43
|
+
if (preset.type === "standard") {
|
|
44
|
+
request.standardView = preset.standardView;
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
request.position = preset.position;
|
|
48
|
+
request.lookAt = preset.lookAt;
|
|
49
|
+
request.up = preset.up;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
else if (input.modelPath) {
|
|
53
|
+
request.cameraName = input.cameraName ?? "Product Hero";
|
|
54
|
+
}
|
|
55
|
+
return request;
|
|
56
|
+
}
|
|
57
|
+
async function resolveMaterialAssignments(config, assignments) {
|
|
58
|
+
if (assignments.length === 0)
|
|
59
|
+
return [];
|
|
60
|
+
const needsPresets = assignments.some((assignment) => assignment.presetName !== undefined);
|
|
61
|
+
const presets = needsPresets ? await loadMaterialPresets(config) : [];
|
|
62
|
+
return assignments.map((assignment) => {
|
|
63
|
+
if (!assignment.presetName)
|
|
64
|
+
return { ...assignment };
|
|
65
|
+
const preset = findMaterialPreset(presets, assignment.presetName);
|
|
66
|
+
if (!preset) {
|
|
67
|
+
const available = presets.map((entry) => entry.name).join(", ") || "(none)";
|
|
68
|
+
throw new Error(`Material preset not found: "${assignment.presetName}". Available: ${available}`);
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
objectName: assignment.objectName,
|
|
72
|
+
objectPath: assignment.objectPath,
|
|
73
|
+
presetName: preset.name,
|
|
74
|
+
materialName: preset.materialName,
|
|
75
|
+
materialPath: preset.materialPath,
|
|
76
|
+
};
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
function safeStem(sourcePath) {
|
|
80
|
+
const parsed = path.parse(sourcePath);
|
|
81
|
+
const normalized = parsed.name.trim().replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
82
|
+
return normalized || "product";
|
|
83
|
+
}
|
package/dist/runner.js
CHANGED
|
@@ -30,6 +30,22 @@ async function runKeyShot(config, request) {
|
|
|
30
30
|
catch (error) {
|
|
31
31
|
return localFailure(errorMessage(error));
|
|
32
32
|
}
|
|
33
|
+
if (normalizedRequest.operation === "product_render") {
|
|
34
|
+
for (const field of ["modelPath", "baseScenePath"]) {
|
|
35
|
+
const value = normalizedRequest[field];
|
|
36
|
+
if (typeof value === "string" && !(await exists(value))) {
|
|
37
|
+
return localFailure(`${field === "modelPath" ? "Model" : "Base scene"} file not found: ${value}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (normalizedRequest.overwrite !== true) {
|
|
41
|
+
const protectedOutputs = [normalizedRequest.outputScenePath, normalizedRequest.outputPath];
|
|
42
|
+
for (const output of protectedOutputs) {
|
|
43
|
+
if (typeof output === "string" && await exists(output)) {
|
|
44
|
+
return localFailure(`Output already exists and overwrite is false: ${output}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
33
49
|
const id = `${Date.now()}-${randomUUID()}`;
|
|
34
50
|
const argsPath = path.join(config.tmpDir, `${id}.args.json`);
|
|
35
51
|
const resultPath = path.join(config.tmpDir, `${id}.result.json`);
|
package/dist/schemas.js
CHANGED
|
@@ -74,6 +74,94 @@ export const applyMaterialSchema = applyMaterialInputSchema.refine((value) => va
|
|
|
74
74
|
message: "Provide materialName or materialPath.",
|
|
75
75
|
});
|
|
76
76
|
const vector3 = z.tuple([z.number(), z.number(), z.number()]);
|
|
77
|
+
export const productMaterialAssignmentSchema = z.object({
|
|
78
|
+
objectName: z.string().min(1).optional(),
|
|
79
|
+
objectPath: z.string().min(1).optional(),
|
|
80
|
+
presetName: z.string().min(1).optional(),
|
|
81
|
+
materialName: z.string().min(1).optional(),
|
|
82
|
+
materialPath: z.string().min(1).optional(),
|
|
83
|
+
}).superRefine((value, context) => {
|
|
84
|
+
const targets = [value.objectName, value.objectPath].filter((item) => item !== undefined);
|
|
85
|
+
const materials = [value.presetName, value.materialName, value.materialPath].filter((item) => item !== undefined);
|
|
86
|
+
if (targets.length !== 1) {
|
|
87
|
+
context.addIssue({ code: z.ZodIssueCode.custom, message: "Choose exactly one objectName or objectPath." });
|
|
88
|
+
}
|
|
89
|
+
if (materials.length !== 1) {
|
|
90
|
+
context.addIssue({
|
|
91
|
+
code: z.ZodIssueCode.custom,
|
|
92
|
+
message: "Choose exactly one presetName, materialName, or materialPath.",
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
export const productRenderInputSchema = z.object({
|
|
97
|
+
modelPath: optionalPath,
|
|
98
|
+
scenePath: optionalPath,
|
|
99
|
+
baseScenePath: optionalPath,
|
|
100
|
+
outputScenePath: optionalPath,
|
|
101
|
+
renderMode: z.enum(["single", "allCameras"]).default("single"),
|
|
102
|
+
outputPath: optionalPath,
|
|
103
|
+
outputDir: optionalPath,
|
|
104
|
+
centerGeometry: z.boolean().optional(),
|
|
105
|
+
snapToGround: z.boolean().optional(),
|
|
106
|
+
adjustCameraLookAt: z.boolean().optional(),
|
|
107
|
+
adjustEnvironment: z.boolean().optional(),
|
|
108
|
+
materialAssignments: z.array(productMaterialAssignmentSchema).optional(),
|
|
109
|
+
cameraPresetName: z.string().min(1).optional(),
|
|
110
|
+
cameraName: z.string().min(1).optional(),
|
|
111
|
+
position: vector3.optional(),
|
|
112
|
+
lookAt: vector3.optional(),
|
|
113
|
+
up: vector3.optional(),
|
|
114
|
+
distance: z.number().positive().optional(),
|
|
115
|
+
fieldOfView: z.number().gt(0).lt(180).optional(),
|
|
116
|
+
focalLength: z.number().min(5).max(200).optional(),
|
|
117
|
+
environmentName: z.string().min(1).optional(),
|
|
118
|
+
environmentPath: z.string().min(1).optional(),
|
|
119
|
+
brightness: z.number().positive().optional(),
|
|
120
|
+
rotation: z.number().min(0).lt(360).optional(),
|
|
121
|
+
width: z.number().int().positive().optional(),
|
|
122
|
+
height: z.number().int().positive().optional(),
|
|
123
|
+
samples: z.number().int().positive().optional(),
|
|
124
|
+
maxTimeSeconds: z.number().positive().optional(),
|
|
125
|
+
format: imageFormat.optional(),
|
|
126
|
+
overwrite: z.boolean().default(false),
|
|
127
|
+
continueOnError: z.boolean().default(true),
|
|
128
|
+
});
|
|
129
|
+
export const productRenderSchema = productRenderInputSchema.superRefine((value, context) => {
|
|
130
|
+
if ((value.modelPath === undefined) === (value.scenePath === undefined)) {
|
|
131
|
+
context.addIssue({ code: z.ZodIssueCode.custom, message: "Provide exactly one modelPath or scenePath." });
|
|
132
|
+
}
|
|
133
|
+
if (value.baseScenePath !== undefined && value.modelPath === undefined) {
|
|
134
|
+
context.addIssue({ code: z.ZodIssueCode.custom, message: "baseScenePath requires modelPath.", path: ["baseScenePath"] });
|
|
135
|
+
}
|
|
136
|
+
const importFields = ["centerGeometry", "snapToGround", "adjustCameraLookAt", "adjustEnvironment"];
|
|
137
|
+
if (value.scenePath !== undefined && importFields.some((field) => value[field] !== undefined)) {
|
|
138
|
+
context.addIssue({ code: z.ZodIssueCode.custom, message: "Import options can only be used with modelPath." });
|
|
139
|
+
}
|
|
140
|
+
if (value.renderMode === "single" && value.outputDir !== undefined) {
|
|
141
|
+
context.addIssue({ code: z.ZodIssueCode.custom, message: "outputDir is only valid for allCameras mode.", path: ["outputDir"] });
|
|
142
|
+
}
|
|
143
|
+
if (value.renderMode === "allCameras" && value.outputPath !== undefined) {
|
|
144
|
+
context.addIssue({ code: z.ZodIssueCode.custom, message: "outputPath is only valid for single mode.", path: ["outputPath"] });
|
|
145
|
+
}
|
|
146
|
+
if ((value.position === undefined) !== (value.lookAt === undefined)) {
|
|
147
|
+
context.addIssue({ code: z.ZodIssueCode.custom, message: "Provide position and lookAt together.", path: ["lookAt"] });
|
|
148
|
+
}
|
|
149
|
+
if (value.up !== undefined && value.position === undefined) {
|
|
150
|
+
context.addIssue({ code: z.ZodIssueCode.custom, message: "up requires position and lookAt.", path: ["up"] });
|
|
151
|
+
}
|
|
152
|
+
if (value.cameraPresetName !== undefined && value.position !== undefined) {
|
|
153
|
+
context.addIssue({ code: z.ZodIssueCode.custom, message: "cameraPresetName cannot be combined with a custom position." });
|
|
154
|
+
}
|
|
155
|
+
if (value.fieldOfView !== undefined && value.focalLength !== undefined) {
|
|
156
|
+
context.addIssue({ code: z.ZodIssueCode.custom, message: "Choose either fieldOfView or focalLength, not both.", path: ["focalLength"] });
|
|
157
|
+
}
|
|
158
|
+
if (value.environmentName !== undefined && value.environmentPath !== undefined) {
|
|
159
|
+
context.addIssue({ code: z.ZodIssueCode.custom, message: "Choose either environmentName or environmentPath, not both." });
|
|
160
|
+
}
|
|
161
|
+
if (value.samples !== undefined && value.maxTimeSeconds !== undefined) {
|
|
162
|
+
context.addIssue({ code: z.ZodIssueCode.custom, message: "Choose either samples or maxTimeSeconds, not both.", path: ["maxTimeSeconds"] });
|
|
163
|
+
}
|
|
164
|
+
});
|
|
77
165
|
const setCameraBaseSchema = z.object({
|
|
78
166
|
scenePath: z.string().min(1),
|
|
79
167
|
cameraName: z.string().min(1).optional(),
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.
|
|
1
|
+
export const VERSION = "0.8.0";
|
package/package.json
CHANGED
|
@@ -4,6 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { findCameraPreset, loadCameraPresets } from "../dist/camera-presets.js";
|
|
6
6
|
import { getConfig } from "../dist/config.js";
|
|
7
|
+
import { prepareProductRenderRequest } from "../dist/product-render.js";
|
|
7
8
|
import { runKeyShotSerialized } from "../dist/runner.js";
|
|
8
9
|
|
|
9
10
|
const repoRoot = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
|
|
@@ -36,8 +37,8 @@ async function applyStandardPreset(label, scenePath, presetName, outputScenePath
|
|
|
36
37
|
});
|
|
37
38
|
}
|
|
38
39
|
|
|
39
|
-
const status = await run("1/
|
|
40
|
-
const imported = await run("2/
|
|
40
|
+
const status = await run("1/13 KeyShot status", { operation: "status" });
|
|
41
|
+
const imported = await run("2/13 Import, center, and ground generated OBJ", {
|
|
41
42
|
operation: "import_model",
|
|
42
43
|
modelPath,
|
|
43
44
|
outputScenePath: "demo/keyshot-mcp-demo-import.bip",
|
|
@@ -49,7 +50,7 @@ const imported = await run("2/11 Import, center, and ground generated OBJ", {
|
|
|
49
50
|
const importedScene = imported.outputFiles[0];
|
|
50
51
|
if (!importedScene) throw new Error("Import did not return a saved scene path.");
|
|
51
52
|
|
|
52
|
-
const inspected = await run("3/
|
|
53
|
+
const inspected = await run("3/13 Inspect imported scene", {
|
|
53
54
|
operation: "inspect_scene",
|
|
54
55
|
scenePath: importedScene,
|
|
55
56
|
});
|
|
@@ -59,7 +60,7 @@ if (!Array.isArray(objects) || objects.length === 0) {
|
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
const firstCameraResult = await applyStandardPreset(
|
|
62
|
-
"4/
|
|
63
|
+
"4/13 Apply Front camera preset",
|
|
63
64
|
importedScene,
|
|
64
65
|
"Front",
|
|
65
66
|
"demo/keyshot-mcp-demo-preset-front.bip",
|
|
@@ -67,7 +68,7 @@ const firstCameraResult = await applyStandardPreset(
|
|
|
67
68
|
const firstCameraScene = firstCameraResult.outputFiles[0];
|
|
68
69
|
if (!firstCameraScene) throw new Error("Front preset did not return a saved scene path.");
|
|
69
70
|
|
|
70
|
-
const focalLengthResult = await run("5/
|
|
71
|
+
const focalLengthResult = await run("5/13 Set Front camera focal length", {
|
|
71
72
|
operation: "set_camera",
|
|
72
73
|
scenePath: firstCameraScene,
|
|
73
74
|
cameraName: "Front",
|
|
@@ -77,7 +78,7 @@ const focalLengthResult = await run("5/11 Set Front camera focal length", {
|
|
|
77
78
|
const focalLengthScene = focalLengthResult.outputFiles[0];
|
|
78
79
|
if (!focalLengthScene) throw new Error("Focal-length update did not return a saved scene path.");
|
|
79
80
|
|
|
80
|
-
const environmentResult = await run("6/
|
|
81
|
+
const environmentResult = await run("6/13 Rotate the active environment", {
|
|
81
82
|
operation: "set_environment",
|
|
82
83
|
scenePath: focalLengthScene,
|
|
83
84
|
rotation: 45,
|
|
@@ -87,7 +88,7 @@ const environmentScene = environmentResult.outputFiles[0];
|
|
|
87
88
|
if (!environmentScene) throw new Error("Environment update did not return a saved scene path.");
|
|
88
89
|
|
|
89
90
|
const secondCameraResult = await applyStandardPreset(
|
|
90
|
-
"7/
|
|
91
|
+
"7/13 Apply Isometric camera preset",
|
|
91
92
|
environmentScene,
|
|
92
93
|
"Isometric",
|
|
93
94
|
"demo/keyshot-mcp-demo-presets.bip",
|
|
@@ -95,7 +96,7 @@ const secondCameraResult = await applyStandardPreset(
|
|
|
95
96
|
const cameraScene = secondCameraResult.outputFiles[0];
|
|
96
97
|
if (!cameraScene) throw new Error("Isometric preset did not return a saved scene path.");
|
|
97
98
|
|
|
98
|
-
const fieldOfViewResult = await run("8/
|
|
99
|
+
const fieldOfViewResult = await run("8/13 Set Isometric camera field of view and distance", {
|
|
99
100
|
operation: "set_camera",
|
|
100
101
|
scenePath: cameraScene,
|
|
101
102
|
cameraName: "Isometric",
|
|
@@ -106,7 +107,7 @@ const fieldOfViewResult = await run("8/11 Set Isometric camera field of view and
|
|
|
106
107
|
const productCameraScene = fieldOfViewResult.outputFiles[0];
|
|
107
108
|
if (!productCameraScene) throw new Error("Field-of-view update did not return a saved scene path.");
|
|
108
109
|
|
|
109
|
-
const rendered = await run("9/
|
|
110
|
+
const rendered = await run("9/13 Discover and render every camera", {
|
|
110
111
|
operation: "render_all_cameras",
|
|
111
112
|
scenePath: productCameraScene,
|
|
112
113
|
outputDir: "demo/all-cameras",
|
|
@@ -139,8 +140,42 @@ const minimumSizes = await Promise.all(namedResults.map(async (entry) => (await
|
|
|
139
140
|
if (minimumSizes.some((size) => size < 10000)) {
|
|
140
141
|
throw new Error("A demo camera render is unexpectedly small and may be blank.");
|
|
141
142
|
}
|
|
142
|
-
process.stdout.write("10/
|
|
143
|
-
process.stdout.write("11/
|
|
143
|
+
process.stdout.write("10/13 Verify two different camera images... ok\n");
|
|
144
|
+
process.stdout.write("11/13 Verify composition, lens, and environment operations... ok\n");
|
|
145
|
+
|
|
146
|
+
const oneClickModelRequest = await prepareProductRenderRequest(config, {
|
|
147
|
+
modelPath,
|
|
148
|
+
outputScenePath: "demo/one-click-cube.bip",
|
|
149
|
+
outputPath: "demo/one-click-cube.png",
|
|
150
|
+
renderMode: "single",
|
|
151
|
+
width: 640,
|
|
152
|
+
height: 480,
|
|
153
|
+
maxTimeSeconds: 8,
|
|
154
|
+
format: "png",
|
|
155
|
+
overwrite: true,
|
|
156
|
+
continueOnError: true,
|
|
157
|
+
});
|
|
158
|
+
const oneClickModel = await run("12/13 One-click model-to-product render", oneClickModelRequest);
|
|
159
|
+
if (!oneClickModel.data?.savedScene || oneClickModel.data?.renders?.length !== 1) {
|
|
160
|
+
throw new Error("One-click model workflow did not return a scene and one render.");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const oneClickSceneRequest = await prepareProductRenderRequest(config, {
|
|
164
|
+
scenePath: productCameraScene,
|
|
165
|
+
outputScenePath: "demo/one-click-existing-scene.bip",
|
|
166
|
+
outputDir: "demo/one-click-all-cameras",
|
|
167
|
+
renderMode: "allCameras",
|
|
168
|
+
width: 640,
|
|
169
|
+
height: 480,
|
|
170
|
+
maxTimeSeconds: 8,
|
|
171
|
+
format: "png",
|
|
172
|
+
overwrite: true,
|
|
173
|
+
continueOnError: true,
|
|
174
|
+
});
|
|
175
|
+
const oneClickScene = await run("13/13 One-click existing-scene all-camera render", oneClickSceneRequest);
|
|
176
|
+
if (!oneClickScene.data?.savedScene || oneClickScene.data?.renders?.length < 2) {
|
|
177
|
+
throw new Error("One-click scene workflow did not preserve the scene and render all cameras.");
|
|
178
|
+
}
|
|
144
179
|
|
|
145
180
|
console.log(JSON.stringify({
|
|
146
181
|
keyshotVersion: status.data?.version ?? null,
|
|
@@ -148,6 +183,7 @@ console.log(JSON.stringify({
|
|
|
148
183
|
scenePath: productCameraScene,
|
|
149
184
|
cameraCount: renderData.total,
|
|
150
185
|
renderedImages: rendered.outputFiles,
|
|
186
|
+
oneClickOutputs: [...oneClickModel.outputFiles, ...oneClickScene.outputFiles],
|
|
151
187
|
verifiedPresets: namedResults.map((entry) => entry.camera),
|
|
152
188
|
verifiedControls: {
|
|
153
189
|
importOptions: ["centerGeometry", "snapToGround", "adjustCameraLookAt", "adjustEnvironment"],
|
|
@@ -39,6 +39,8 @@ def main():
|
|
|
39
39
|
data = batch_render(payload, output_files, warnings)
|
|
40
40
|
elif operation == "render_all_cameras":
|
|
41
41
|
data = render_all_cameras(payload, output_files, warnings)
|
|
42
|
+
elif operation == "product_render":
|
|
43
|
+
data = product_render(payload, output_files, warnings)
|
|
42
44
|
elif operation == "import_model":
|
|
43
45
|
data = import_model(payload, output_files, warnings)
|
|
44
46
|
elif operation == "apply_material":
|
|
@@ -57,6 +59,8 @@ def main():
|
|
|
57
59
|
operation_error = None
|
|
58
60
|
if operation == "render_all_cameras" and data.get("failed", 0) > 0:
|
|
59
61
|
operation_error = "%s of %s camera render(s) failed." % (data["failed"], data["total"])
|
|
62
|
+
elif operation == "product_render":
|
|
63
|
+
operation_error = data.get("operationError")
|
|
60
64
|
write_result(result_path, operation_error is None, data, output_files, warnings, operation_error)
|
|
61
65
|
except Exception as exc:
|
|
62
66
|
warnings.append(traceback.format_exc())
|
|
@@ -227,6 +231,13 @@ def batch_render(payload, output_files, warnings):
|
|
|
227
231
|
|
|
228
232
|
|
|
229
233
|
def render_all_cameras(payload, output_files, warnings):
|
|
234
|
+
scene_path = payload.get("scenePath")
|
|
235
|
+
if not scene_path:
|
|
236
|
+
raise RuntimeError("scenePath is required")
|
|
237
|
+
return render_all_cameras_current(payload, output_files, warnings)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def render_all_cameras_current(payload, output_files, warnings):
|
|
230
241
|
scene_path = payload.get("scenePath")
|
|
231
242
|
output_dir = payload.get("outputDir")
|
|
232
243
|
discovered_cameras = camera_names()
|
|
@@ -238,8 +249,6 @@ def render_all_cameras(payload, output_files, warnings):
|
|
|
238
249
|
overwrite = bool(payload.get("overwrite", False))
|
|
239
250
|
continue_on_error = payload.get("continueOnError", True) is not False
|
|
240
251
|
|
|
241
|
-
if not scene_path:
|
|
242
|
-
raise RuntimeError("scenePath is required")
|
|
243
252
|
if not output_dir:
|
|
244
253
|
raise RuntimeError("outputDir is required")
|
|
245
254
|
if not cameras:
|
|
@@ -320,14 +329,168 @@ def unique_camera_output_path(output_dir, camera, image_format, used_stems):
|
|
|
320
329
|
return os.path.join(output_dir, "%s.%s" % (stem, image_format))
|
|
321
330
|
|
|
322
331
|
|
|
332
|
+
def product_render(payload, output_files, warnings):
|
|
333
|
+
stages = []
|
|
334
|
+
source_type = "model" if payload.get("modelPath") else "scene"
|
|
335
|
+
source_path = payload.get("modelPath") or payload.get("scenePath")
|
|
336
|
+
result = {
|
|
337
|
+
"sourceType": source_type,
|
|
338
|
+
"sourcePath": source_path,
|
|
339
|
+
"savedScene": None,
|
|
340
|
+
"renderMode": payload.get("renderMode") or "single",
|
|
341
|
+
"renders": [],
|
|
342
|
+
"materialAssignments": [],
|
|
343
|
+
"camera": None,
|
|
344
|
+
"environment": None,
|
|
345
|
+
"stages": stages,
|
|
346
|
+
"operationError": None,
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
try:
|
|
350
|
+
if source_type == "model":
|
|
351
|
+
source_data = import_model_current(payload, warnings)
|
|
352
|
+
else:
|
|
353
|
+
if not source_path:
|
|
354
|
+
raise RuntimeError("scenePath is required")
|
|
355
|
+
source_data = {"openedScene": source_path}
|
|
356
|
+
stages.append(product_stage("source", True, source_data))
|
|
357
|
+
except Exception as exc:
|
|
358
|
+
return product_failure(result, stages, "source", exc, warnings)
|
|
359
|
+
|
|
360
|
+
assignments = payload.get("materialAssignments") or []
|
|
361
|
+
try:
|
|
362
|
+
applied_materials = [apply_material_current(assignment, warnings) for assignment in assignments]
|
|
363
|
+
result["materialAssignments"] = applied_materials
|
|
364
|
+
stages.append(product_stage("materials", True, applied_materials, len(assignments) == 0))
|
|
365
|
+
except Exception as exc:
|
|
366
|
+
return product_failure(result, stages, "materials", exc, warnings)
|
|
367
|
+
|
|
368
|
+
try:
|
|
369
|
+
camera_data = configure_product_camera(payload, output_files, warnings)
|
|
370
|
+
result["camera"] = camera_data
|
|
371
|
+
stages.append(product_stage("camera", True, camera_data, camera_data is None))
|
|
372
|
+
except Exception as exc:
|
|
373
|
+
return product_failure(result, stages, "camera", exc, warnings)
|
|
374
|
+
|
|
375
|
+
environment_requested = any(
|
|
376
|
+
payload.get(name) is not None
|
|
377
|
+
for name in ("environmentName", "environmentPath", "brightness", "rotation")
|
|
378
|
+
)
|
|
379
|
+
try:
|
|
380
|
+
environment_data = set_environment(payload, output_files, warnings, persist=False) if environment_requested else None
|
|
381
|
+
result["environment"] = environment_data
|
|
382
|
+
stages.append(product_stage("environment", True, environment_data, not environment_requested))
|
|
383
|
+
except Exception as exc:
|
|
384
|
+
return product_failure(result, stages, "environment", exc, warnings)
|
|
385
|
+
|
|
386
|
+
output_scene_path = payload.get("outputScenePath")
|
|
387
|
+
try:
|
|
388
|
+
if not output_scene_path:
|
|
389
|
+
raise RuntimeError("outputScenePath is required")
|
|
390
|
+
save_to(output_scene_path)
|
|
391
|
+
output_files.append(output_scene_path)
|
|
392
|
+
result["savedScene"] = output_scene_path
|
|
393
|
+
stages.append(product_stage("save", True, {"savedScene": output_scene_path}))
|
|
394
|
+
except Exception as exc:
|
|
395
|
+
return product_failure(result, stages, "save", exc, warnings)
|
|
396
|
+
|
|
397
|
+
try:
|
|
398
|
+
if result["renderMode"] == "allCameras":
|
|
399
|
+
render_payload = dict(payload)
|
|
400
|
+
render_payload["scenePath"] = output_scene_path
|
|
401
|
+
render_data = render_all_cameras_current(render_payload, output_files, warnings)
|
|
402
|
+
result["renders"] = render_data.get("results", [])
|
|
403
|
+
render_ok = render_data.get("failed", 0) == 0
|
|
404
|
+
stages.append(product_stage("render", render_ok, render_data))
|
|
405
|
+
if not render_ok:
|
|
406
|
+
result["operationError"] = "%s of %s camera render(s) failed." % (
|
|
407
|
+
render_data.get("failed", 0),
|
|
408
|
+
render_data.get("total", 0),
|
|
409
|
+
)
|
|
410
|
+
else:
|
|
411
|
+
output_path = payload.get("outputPath")
|
|
412
|
+
if not output_path:
|
|
413
|
+
raise RuntimeError("outputPath is required for single render mode")
|
|
414
|
+
if os.path.exists(output_path) and not payload.get("overwrite", False):
|
|
415
|
+
raise RuntimeError("Output already exists and overwrite is false: %s" % output_path)
|
|
416
|
+
render_payload = dict(payload)
|
|
417
|
+
if payload.get("cameraName"):
|
|
418
|
+
render_payload["camera"] = payload.get("cameraName")
|
|
419
|
+
render_data = render(render_payload, output_files, warnings)
|
|
420
|
+
result["renders"] = [{"camera": render_payload.get("camera"), "outputPath": render_data["rendered"], "ok": True}]
|
|
421
|
+
stages.append(product_stage("render", True, render_data))
|
|
422
|
+
except Exception as exc:
|
|
423
|
+
return product_failure(result, stages, "render", exc, warnings)
|
|
424
|
+
|
|
425
|
+
return result
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def configure_product_camera(payload, output_files, warnings):
|
|
429
|
+
camera_name = payload.get("cameraName")
|
|
430
|
+
standard_view = payload.get("standardView")
|
|
431
|
+
transform_requested = payload.get("position") is not None
|
|
432
|
+
lens_requested = any(payload.get(name) is not None for name in ("distance", "fieldOfView", "focalLength"))
|
|
433
|
+
|
|
434
|
+
if standard_view:
|
|
435
|
+
standard_data = set_standard_camera(payload, output_files, warnings, persist=False)
|
|
436
|
+
if lens_requested:
|
|
437
|
+
lens_payload = {
|
|
438
|
+
"cameraName": camera_name,
|
|
439
|
+
"distance": payload.get("distance"),
|
|
440
|
+
"fieldOfView": payload.get("fieldOfView"),
|
|
441
|
+
"focalLength": payload.get("focalLength"),
|
|
442
|
+
}
|
|
443
|
+
lens_data = set_camera(lens_payload, output_files, warnings, persist=False)
|
|
444
|
+
standard_data.update({
|
|
445
|
+
"distance": lens_data.get("distance"),
|
|
446
|
+
"fieldOfView": lens_data.get("fieldOfView"),
|
|
447
|
+
"focalLength": lens_data.get("focalLength"),
|
|
448
|
+
})
|
|
449
|
+
standard_data["presetName"] = payload.get("cameraPresetName")
|
|
450
|
+
return standard_data
|
|
451
|
+
|
|
452
|
+
if transform_requested or lens_requested:
|
|
453
|
+
camera_data = set_camera(payload, output_files, warnings, persist=False)
|
|
454
|
+
camera_data["presetName"] = payload.get("cameraPresetName")
|
|
455
|
+
return camera_data
|
|
456
|
+
|
|
457
|
+
if camera_name:
|
|
458
|
+
if camera_name not in camera_names():
|
|
459
|
+
raise RuntimeError("Camera not found: %s" % camera_name)
|
|
460
|
+
call_non_false_variants("activate camera", lambda: lux.setCamera(camera_name))
|
|
461
|
+
return {"cameraName": camera_name, "selected": True}
|
|
462
|
+
|
|
463
|
+
return None
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def product_stage(name, ok, data=None, skipped=False, error=None):
|
|
467
|
+
return {"name": name, "ok": ok, "skipped": skipped, "data": data, "error": error}
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def product_failure(result, stages, stage_name, error, warnings):
|
|
471
|
+
message = "Product render stage '%s' failed: %s" % (stage_name, error)
|
|
472
|
+
stages.append(product_stage(stage_name, False, error=str(error)))
|
|
473
|
+
warnings.append(message)
|
|
474
|
+
result["operationError"] = message
|
|
475
|
+
return result
|
|
476
|
+
|
|
477
|
+
|
|
323
478
|
def import_model(payload, output_files, warnings):
|
|
324
|
-
|
|
479
|
+
data = import_model_current(payload, warnings)
|
|
325
480
|
output_scene_path = payload.get("outputScenePath")
|
|
481
|
+
if not output_scene_path:
|
|
482
|
+
raise RuntimeError("outputScenePath is required")
|
|
483
|
+
save_to(output_scene_path)
|
|
484
|
+
output_files.append(output_scene_path)
|
|
485
|
+
data["savedScene"] = output_scene_path
|
|
486
|
+
return data
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def import_model_current(payload, warnings):
|
|
490
|
+
model_path = payload.get("modelPath")
|
|
326
491
|
base_scene_path = payload.get("baseScenePath")
|
|
327
492
|
if not model_path or not os.path.exists(model_path):
|
|
328
493
|
raise RuntimeError("Model file not found: %s" % model_path)
|
|
329
|
-
if not output_scene_path:
|
|
330
|
-
raise RuntimeError("outputScenePath is required")
|
|
331
494
|
|
|
332
495
|
if base_scene_path:
|
|
333
496
|
if not os.path.exists(base_scene_path):
|
|
@@ -374,13 +537,10 @@ def import_model(payload, output_files, warnings):
|
|
|
374
537
|
lambda: lux.importFile(model_path),
|
|
375
538
|
lambda: lux.importFile(str(model_path)),
|
|
376
539
|
)
|
|
377
|
-
save_to(output_scene_path)
|
|
378
|
-
output_files.append(output_scene_path)
|
|
379
540
|
return {
|
|
380
541
|
"importedModel": model_path,
|
|
381
542
|
"baseScene": base_scene_path,
|
|
382
543
|
"importOptions": requested_options,
|
|
383
|
-
"savedScene": output_scene_path,
|
|
384
544
|
}
|
|
385
545
|
|
|
386
546
|
|
|
@@ -407,7 +567,17 @@ def new_scene(warnings):
|
|
|
407
567
|
|
|
408
568
|
|
|
409
569
|
def apply_material(payload, output_files, warnings):
|
|
570
|
+
data = apply_material_current(payload, warnings)
|
|
410
571
|
output_scene_path = payload.get("outputScenePath")
|
|
572
|
+
if not output_scene_path:
|
|
573
|
+
raise RuntimeError("outputScenePath is required")
|
|
574
|
+
save_to(output_scene_path)
|
|
575
|
+
output_files.append(output_scene_path)
|
|
576
|
+
data["savedScene"] = output_scene_path
|
|
577
|
+
return data
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def apply_material_current(payload, warnings):
|
|
411
581
|
target = find_object(payload.get("objectName"), payload.get("objectPath"))
|
|
412
582
|
if target is None:
|
|
413
583
|
raise RuntimeError("Object not found. Provide a valid objectName or objectPath.")
|
|
@@ -420,16 +590,14 @@ def apply_material(payload, output_files, warnings):
|
|
|
420
590
|
lambda: lux.setObjectMaterial(target, payload.get("materialPath")),
|
|
421
591
|
)
|
|
422
592
|
|
|
423
|
-
save_to(output_scene_path)
|
|
424
|
-
output_files.append(output_scene_path)
|
|
425
593
|
return {
|
|
426
594
|
"object": describe_object(target),
|
|
427
595
|
"material": serialize_value(material),
|
|
428
|
-
"
|
|
596
|
+
"presetName": payload.get("presetName"),
|
|
429
597
|
}
|
|
430
598
|
|
|
431
599
|
|
|
432
|
-
def set_camera(payload, output_files, warnings):
|
|
600
|
+
def set_camera(payload, output_files, warnings, persist=True):
|
|
433
601
|
output_scene_path = payload.get("outputScenePath")
|
|
434
602
|
camera_name = payload.get("cameraName") or "MCP Camera"
|
|
435
603
|
position = payload.get("position")
|
|
@@ -533,9 +701,7 @@ def set_camera(payload, output_files, warnings):
|
|
|
533
701
|
except RuntimeError:
|
|
534
702
|
warnings.append("The camera was updated, but this KeyShot version could not save the named view.")
|
|
535
703
|
|
|
536
|
-
|
|
537
|
-
output_files.append(output_scene_path)
|
|
538
|
-
return {
|
|
704
|
+
data = {
|
|
539
705
|
"cameraName": camera_name,
|
|
540
706
|
"position": position,
|
|
541
707
|
"lookAt": look_at,
|
|
@@ -543,11 +709,17 @@ def set_camera(payload, output_files, warnings):
|
|
|
543
709
|
"distance": distance,
|
|
544
710
|
"fieldOfView": field_of_view,
|
|
545
711
|
"focalLength": focal_length,
|
|
546
|
-
"savedScene": output_scene_path,
|
|
547
712
|
}
|
|
713
|
+
if persist:
|
|
714
|
+
if not output_scene_path:
|
|
715
|
+
raise RuntimeError("outputScenePath is required")
|
|
716
|
+
save_to(output_scene_path)
|
|
717
|
+
output_files.append(output_scene_path)
|
|
718
|
+
data["savedScene"] = output_scene_path
|
|
719
|
+
return data
|
|
548
720
|
|
|
549
721
|
|
|
550
|
-
def set_standard_camera(payload, output_files, warnings):
|
|
722
|
+
def set_standard_camera(payload, output_files, warnings, persist=True):
|
|
551
723
|
output_scene_path = payload.get("outputScenePath")
|
|
552
724
|
camera_name = payload.get("cameraName") or "MCP Camera"
|
|
553
725
|
standard_view = str(payload.get("standardView") or "").lower()
|
|
@@ -586,16 +758,20 @@ def set_standard_camera(payload, output_files, warnings):
|
|
|
586
758
|
lambda: lux.saveCamera(),
|
|
587
759
|
lambda: lux.saveCamera(camera_name),
|
|
588
760
|
)
|
|
589
|
-
|
|
590
|
-
output_files.append(output_scene_path)
|
|
591
|
-
return {
|
|
761
|
+
data = {
|
|
592
762
|
"cameraName": camera_name,
|
|
593
763
|
"standardView": standard_view,
|
|
594
|
-
"savedScene": output_scene_path,
|
|
595
764
|
}
|
|
765
|
+
if persist:
|
|
766
|
+
if not output_scene_path:
|
|
767
|
+
raise RuntimeError("outputScenePath is required")
|
|
768
|
+
save_to(output_scene_path)
|
|
769
|
+
output_files.append(output_scene_path)
|
|
770
|
+
data["savedScene"] = output_scene_path
|
|
771
|
+
return data
|
|
596
772
|
|
|
597
773
|
|
|
598
|
-
def set_environment(payload, output_files, warnings):
|
|
774
|
+
def set_environment(payload, output_files, warnings, persist=True):
|
|
599
775
|
output_scene_path = payload.get("outputScenePath")
|
|
600
776
|
environment_name = payload.get("environmentName")
|
|
601
777
|
environment_path = payload.get("environmentPath")
|
|
@@ -616,6 +792,7 @@ def set_environment(payload, output_files, warnings):
|
|
|
616
792
|
elif environment_name:
|
|
617
793
|
call_variants(
|
|
618
794
|
"set environment name",
|
|
795
|
+
lambda: lux.setActiveEnvironment(environment_name),
|
|
619
796
|
lambda: lux.setEnvironment(environment_name),
|
|
620
797
|
lambda: lux.setEnvironmentByName(environment_name),
|
|
621
798
|
)
|
|
@@ -648,15 +825,19 @@ def set_environment(payload, output_files, warnings):
|
|
|
648
825
|
if not changed:
|
|
649
826
|
raise RuntimeError("No supported environment change was requested.")
|
|
650
827
|
|
|
651
|
-
|
|
652
|
-
output_files.append(output_scene_path)
|
|
653
|
-
return {
|
|
828
|
+
data = {
|
|
654
829
|
"environmentName": environment_name,
|
|
655
830
|
"environmentPath": environment_path,
|
|
656
831
|
"brightness": brightness,
|
|
657
832
|
"rotation": rotation,
|
|
658
|
-
"savedScene": output_scene_path,
|
|
659
833
|
}
|
|
834
|
+
if persist:
|
|
835
|
+
if not output_scene_path:
|
|
836
|
+
raise RuntimeError("outputScenePath is required")
|
|
837
|
+
save_to(output_scene_path)
|
|
838
|
+
output_files.append(output_scene_path)
|
|
839
|
+
data["savedScene"] = output_scene_path
|
|
840
|
+
return data
|
|
660
841
|
|
|
661
842
|
|
|
662
843
|
def active_environment():
|
package/server.json
CHANGED
|
@@ -2,19 +2,19 @@
|
|
|
2
2
|
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
3
|
"name": "io.github.truman-t3/keyshot-mcp",
|
|
4
4
|
"title": "KeyShot MCP",
|
|
5
|
-
"description": "Control local KeyShot
|
|
5
|
+
"description": "Control local KeyShot scenes, cameras, environments, and renders through headless scripting.",
|
|
6
6
|
"websiteUrl": "https://github.com/truman-t3/keyshot-mcp",
|
|
7
7
|
"repository": {
|
|
8
8
|
"id": "1290741614",
|
|
9
9
|
"url": "https://github.com/truman-t3/keyshot-mcp",
|
|
10
10
|
"source": "github"
|
|
11
11
|
},
|
|
12
|
-
"version": "0.
|
|
12
|
+
"version": "0.8.0",
|
|
13
13
|
"packages": [
|
|
14
14
|
{
|
|
15
15
|
"registryType": "npm",
|
|
16
16
|
"identifier": "keyshot-mcp",
|
|
17
|
-
"version": "0.
|
|
17
|
+
"version": "0.8.0",
|
|
18
18
|
"transport": {
|
|
19
19
|
"type": "stdio"
|
|
20
20
|
},
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" width="720" height="360" viewBox="0 0 720 360" role="img" aria-labelledby="title desc">
|
|
2
|
-
<title id="title">Star History for truman-t3/keyshot-mcp</title>
|
|
3
|
-
<desc id="desc">5 GitHub stars through 2026-07-20, plotted by the date each star was added.</desc>
|
|
4
|
-
<style>
|
|
5
|
-
.text { fill: #c9d1d9; font: 16px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
|
6
|
-
.title { fill: #f0f6fc; font: 700 26px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
|
7
|
-
.muted { fill: #8b949e; font: 14px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
|
8
|
-
</style>
|
|
9
|
-
<rect fill="#0d1117" width="720" height="360" rx="16"/>
|
|
10
|
-
<rect fill="#161b22" stroke="#30363d" x="32" y="32" width="656" height="296" rx="12"/>
|
|
11
|
-
<text class="title" x="64" y="78">Star History</text>
|
|
12
|
-
<text class="muted" x="64" y="104">truman-t3/keyshot-mcp</text>
|
|
13
|
-
<rect fill="#21262d" stroke="#30363d" x="548" y="55" width="92" height="36" rx="18"/>
|
|
14
|
-
<text class="text" x="571" y="79">★ 5</text>
|
|
15
|
-
<line stroke="#30363d" x1="88" y1="258" x2="632" y2="258"/>
|
|
16
|
-
<line stroke="#30363d" x1="88" y1="128" x2="88" y2="258"/>
|
|
17
|
-
<text class="muted" x="66" y="263">0</text>
|
|
18
|
-
<text class="muted" x="66" y="136">5</text>
|
|
19
|
-
<text class="muted" x="88" y="288">2026-07-06</text>
|
|
20
|
-
<text class="muted" x="555" y="288">2026-07-20</text>
|
|
21
|
-
<path fill="none" stroke="#58a6ff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" d="M 88 258 H 88.00 V 128.00 H 632"/>
|
|
22
|
-
<circle fill="#58a6ff" stroke="#0d1117" stroke-width="3" cx="88.00" cy="128.00" r="6"/>
|
|
23
|
-
<circle fill="#58a6ff" stroke="#0d1117" stroke-width="3" cx="632" cy="128.00" r="7"/>
|
|
24
|
-
<text class="text" x="486" y="122">Current stars: 5</text>
|
|
25
|
-
</svg>
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" width="720" height="360" viewBox="0 0 720 360" role="img" aria-labelledby="title desc">
|
|
2
|
-
<title id="title">Star History for truman-t3/keyshot-mcp</title>
|
|
3
|
-
<desc id="desc">5 GitHub stars through 2026-07-20, plotted by the date each star was added.</desc>
|
|
4
|
-
<style>
|
|
5
|
-
.text { fill: #24292f; font: 16px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
|
6
|
-
.title { fill: #1f2328; font: 700 26px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
|
7
|
-
.muted { fill: #57606a; font: 14px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
|
8
|
-
</style>
|
|
9
|
-
<rect fill="#ffffff" width="720" height="360" rx="16"/>
|
|
10
|
-
<rect fill="#f6f8fa" stroke="#d0d7de" x="32" y="32" width="656" height="296" rx="12"/>
|
|
11
|
-
<text class="title" x="64" y="78">Star History</text>
|
|
12
|
-
<text class="muted" x="64" y="104">truman-t3/keyshot-mcp</text>
|
|
13
|
-
<rect fill="#ffffff" stroke="#d0d7de" x="548" y="55" width="92" height="36" rx="18"/>
|
|
14
|
-
<text class="text" x="571" y="79">★ 5</text>
|
|
15
|
-
<line stroke="#d0d7de" x1="88" y1="258" x2="632" y2="258"/>
|
|
16
|
-
<line stroke="#d0d7de" x1="88" y1="128" x2="88" y2="258"/>
|
|
17
|
-
<text class="muted" x="66" y="263">0</text>
|
|
18
|
-
<text class="muted" x="66" y="136">5</text>
|
|
19
|
-
<text class="muted" x="88" y="288">2026-07-06</text>
|
|
20
|
-
<text class="muted" x="555" y="288">2026-07-20</text>
|
|
21
|
-
<path fill="none" stroke="#0969da" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" d="M 88 258 H 88.00 V 128.00 H 632"/>
|
|
22
|
-
<circle fill="#0969da" stroke="#ffffff" stroke-width="3" cx="88.00" cy="128.00" r="6"/>
|
|
23
|
-
<circle fill="#0969da" stroke="#ffffff" stroke-width="3" cx="632" cy="128.00" r="7"/>
|
|
24
|
-
<text class="text" x="486" y="122">Current stars: 5</text>
|
|
25
|
-
</svg>
|