keyshot-mcp 0.2.1 → 0.4.2
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 +190 -24
- package/assets/demo/keyshot-mcp-demo.png +0 -0
- package/dist/config.js +5 -0
- package/dist/index.js +62 -6
- package/dist/output-paths.js +55 -0
- package/dist/presets.js +67 -0
- package/dist/queue.js +70 -0
- package/dist/runner.js +107 -45
- package/dist/schemas.js +45 -0
- package/dist/version.js +1 -0
- package/examples/demo/README.md +9 -0
- package/examples/demo/keyshot-mcp-cube.obj +23 -0
- package/package.json +12 -3
- package/presets/materials.json +14 -0
- package/scripts/keyshot-smoke.mjs +67 -0
- package/scripts/keyshot_bridge.py +183 -34
- package/server.json +31 -0
|
@@ -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,53 @@ 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
|
-
|
|
309
|
+
try:
|
|
310
|
+
# Current KeyShot versions expose cameras as names rather than camera
|
|
311
|
+
# objects. saveCamera creates the named camera and setCamera activates it.
|
|
312
|
+
call_variants(
|
|
313
|
+
"create named camera",
|
|
314
|
+
lambda: lux.saveCamera(camera_name),
|
|
315
|
+
lambda: lux.saveCamera(),
|
|
316
|
+
)
|
|
317
|
+
if hasattr(lux, "setCamera"):
|
|
318
|
+
call_variants("activate camera", lambda: lux.setCamera(camera_name))
|
|
319
|
+
except RuntimeError:
|
|
320
|
+
pass
|
|
237
321
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
lambda: camera.setPosition(tuple(position))
|
|
241
|
-
lambda:
|
|
242
|
-
lambda:
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
322
|
+
if camera is not None:
|
|
323
|
+
# Object-level API is available: drive the camera object directly.
|
|
324
|
+
call_variants("set camera position", lambda: camera.setPosition(tuple(position)))
|
|
325
|
+
call_variants("set camera look-at", lambda: camera.setLookAt(tuple(look_at)))
|
|
326
|
+
call_variants("set camera up", lambda: camera.setUp(tuple(up)))
|
|
327
|
+
else:
|
|
328
|
+
# No camera object could be obtained: fall back to the lux-level setters,
|
|
329
|
+
# which address the camera by name. This path never dereferences a None
|
|
330
|
+
# camera object, so it cannot crash when camera creation is unsupported.
|
|
331
|
+
call_variants(
|
|
332
|
+
"set camera position",
|
|
333
|
+
lambda: lux.setCameraPosition(pos=tuple(position)),
|
|
334
|
+
lambda: lux.setCameraPosition(tuple(position)),
|
|
335
|
+
lambda: lux.setCameraPosition(camera_name, tuple(position)),
|
|
336
|
+
)
|
|
337
|
+
call_variants(
|
|
338
|
+
"set camera look-at",
|
|
339
|
+
lambda: lux.setCameraLookAt(pt=tuple(look_at)),
|
|
340
|
+
lambda: lux.setCameraLookAt(0, tuple(look_at)),
|
|
341
|
+
lambda: lux.setCameraLookAt(camera_name, tuple(look_at)),
|
|
342
|
+
)
|
|
343
|
+
call_variants(
|
|
344
|
+
"set camera up",
|
|
345
|
+
lambda: lux.setCameraUp(up=tuple(up)),
|
|
346
|
+
lambda: lux.setCameraUp(tuple(up)),
|
|
347
|
+
lambda: lux.setCameraUp(camera_name, tuple(up)),
|
|
348
|
+
)
|
|
256
349
|
|
|
257
350
|
save_to(output_scene_path)
|
|
258
351
|
output_files.append(output_scene_path)
|
|
@@ -386,13 +479,13 @@ def save_to(output_scene_path):
|
|
|
386
479
|
|
|
387
480
|
|
|
388
481
|
def call_variants(label, *callbacks):
|
|
482
|
+
"""Try each callback in order. The first one that does not raise wins, even
|
|
483
|
+
if it returns None (KeyShot setters typically return nothing). Only if every
|
|
484
|
+
callback raises do we report the combined errors."""
|
|
389
485
|
errors = []
|
|
390
486
|
for callback in callbacks:
|
|
391
487
|
try:
|
|
392
|
-
|
|
393
|
-
if value is not None:
|
|
394
|
-
return value
|
|
395
|
-
return value
|
|
488
|
+
return callback()
|
|
396
489
|
except Exception as exc:
|
|
397
490
|
errors.append(str(exc))
|
|
398
491
|
raise RuntimeError("%s is unsupported or failed: %s" % (label, " | ".join(errors)))
|
|
@@ -407,6 +500,62 @@ def first_success(*callbacks, default=None):
|
|
|
407
500
|
return default
|
|
408
501
|
|
|
409
502
|
|
|
503
|
+
def first_non_none(*callbacks, default=None):
|
|
504
|
+
for callback in callbacks:
|
|
505
|
+
try:
|
|
506
|
+
value = callback()
|
|
507
|
+
if value is not None:
|
|
508
|
+
return value
|
|
509
|
+
except Exception:
|
|
510
|
+
pass
|
|
511
|
+
return default
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def first_camera_object(*callbacks):
|
|
515
|
+
for callback in callbacks:
|
|
516
|
+
try:
|
|
517
|
+
value = callback()
|
|
518
|
+
if is_camera_object(value):
|
|
519
|
+
return value
|
|
520
|
+
except Exception:
|
|
521
|
+
pass
|
|
522
|
+
return None
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def is_camera_object(value):
|
|
526
|
+
if value is None or value is False or value is True:
|
|
527
|
+
return False
|
|
528
|
+
return any(hasattr(value, method) for method in ("setPosition", "setLookAt", "setUp"))
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def build_render_options(samples, max_time):
|
|
532
|
+
if samples is None and max_time is None:
|
|
533
|
+
return None
|
|
534
|
+
|
|
535
|
+
get_options = getattr(lux, "getRenderOptions", None)
|
|
536
|
+
if get_options is None:
|
|
537
|
+
raise RuntimeError("KeyShot lux.getRenderOptions is not available for advanced render settings.")
|
|
538
|
+
|
|
539
|
+
options = get_options()
|
|
540
|
+
if options is None:
|
|
541
|
+
raise RuntimeError("KeyShot returned no RenderOptions object for advanced render settings.")
|
|
542
|
+
|
|
543
|
+
if samples is not None:
|
|
544
|
+
call_variants(
|
|
545
|
+
"set render samples",
|
|
546
|
+
lambda: options.setMaxSamplesRendering(samples),
|
|
547
|
+
lambda: options.setAdvancedRendering(samples),
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
if max_time is not None:
|
|
551
|
+
call_variants(
|
|
552
|
+
"set render max time",
|
|
553
|
+
lambda: options.setMaxTimeRendering(max_time),
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
return options
|
|
557
|
+
|
|
558
|
+
|
|
410
559
|
def serialize_value(value):
|
|
411
560
|
if value is None or isinstance(value, (str, int, float, bool)):
|
|
412
561
|
return value
|
package/server.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
+
"name": "io.github.truman-t3/keyshot-mcp",
|
|
4
|
+
"title": "KeyShot MCP",
|
|
5
|
+
"description": "Control KeyShot Studio through local headless scripting with MCP.",
|
|
6
|
+
"websiteUrl": "https://github.com/truman-t3/keyshot-mcp",
|
|
7
|
+
"repository": {
|
|
8
|
+
"id": "1290741614",
|
|
9
|
+
"url": "https://github.com/truman-t3/keyshot-mcp",
|
|
10
|
+
"source": "github"
|
|
11
|
+
},
|
|
12
|
+
"version": "0.4.2",
|
|
13
|
+
"packages": [
|
|
14
|
+
{
|
|
15
|
+
"registryType": "npm",
|
|
16
|
+
"identifier": "keyshot-mcp",
|
|
17
|
+
"version": "0.4.2",
|
|
18
|
+
"transport": {
|
|
19
|
+
"type": "stdio"
|
|
20
|
+
},
|
|
21
|
+
"environmentVariables": [
|
|
22
|
+
{
|
|
23
|
+
"name": "KEYSHOT_HEADLESS_EXE",
|
|
24
|
+
"description": "Absolute path to KeyShot headless, or a command available on PATH.",
|
|
25
|
+
"isRequired": true,
|
|
26
|
+
"isSecret": false
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
}
|