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.
@@ -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
- call_variants(
120
- "render image",
121
- lambda: lux.renderImage(output_path, width, height),
122
- lambda: lux.renderImage(output_path, width, height, samples) if samples else None,
123
- lambda: lux.renderImage(output_path, width, height, samples, max_time) if samples and max_time else None,
124
- lambda: lux.renderImage(output_path),
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 {"rendered": output_path, "width": width, "height": height}
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 {"importedModel": model_path, "savedScene": output_scene_path}
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 = first_success(
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
- camera = call_variants("save camera", lambda: lux.saveCamera(camera_name))
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
- call_variants(
239
- "set camera position",
240
- lambda: camera.setPosition(tuple(position)),
241
- lambda: lux.setCameraPosition(camera_name, tuple(position)),
242
- lambda: lux.setCameraPosition(tuple(position)),
243
- )
244
- call_variants(
245
- "set camera look-at",
246
- lambda: camera.setLookAt(tuple(look_at)),
247
- lambda: lux.setCameraLookAt(camera_name, tuple(look_at)),
248
- lambda: lux.setCameraLookAt(tuple(look_at)),
249
- )
250
- call_variants(
251
- "set camera up",
252
- lambda: camera.setUp(tuple(up)),
253
- lambda: lux.setCameraUp(camera_name, tuple(up)),
254
- lambda: lux.setCameraUp(tuple(up)),
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
- value = callback()
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
+ }