keyshot-mcp 0.0.0 → 0.2.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.
@@ -0,0 +1,468 @@
1
+ import json
2
+ import os
3
+ import sys
4
+ import traceback
5
+
6
+ try:
7
+ import lux
8
+ except Exception as exc:
9
+ lux = None
10
+ LUX_IMPORT_ERROR = exc
11
+ else:
12
+ LUX_IMPORT_ERROR = None
13
+
14
+
15
+ def main():
16
+ args_path = sys.argv[-2]
17
+ result_path = sys.argv[-1]
18
+
19
+ with open(args_path, "r", encoding="utf-8") as handle:
20
+ payload = json.load(handle)
21
+
22
+ warnings = []
23
+ output_files = []
24
+
25
+ try:
26
+ if lux is None:
27
+ raise RuntimeError("Could not import KeyShot lux module: %s" % LUX_IMPORT_ERROR)
28
+
29
+ operation = payload.get("operation")
30
+ if operation == "status":
31
+ data = status()
32
+ elif operation == "inspect_scene":
33
+ data = inspect_scene()
34
+ elif operation == "render":
35
+ data = render(payload, output_files, warnings)
36
+ elif operation == "batch_render":
37
+ data = batch_render(payload, output_files, warnings)
38
+ elif operation == "import_model":
39
+ data = import_model(payload, output_files, warnings)
40
+ elif operation == "apply_material":
41
+ data = apply_material(payload, output_files, warnings)
42
+ elif operation == "set_camera":
43
+ data = set_camera(payload, output_files, warnings)
44
+ elif operation == "set_environment":
45
+ data = set_environment(payload, output_files, warnings)
46
+ elif operation == "save_scene":
47
+ data = save_scene(payload, output_files, warnings)
48
+ else:
49
+ raise RuntimeError("Unsupported operation: %s" % operation)
50
+
51
+ write_result(result_path, True, data, output_files, warnings, None)
52
+ except Exception as exc:
53
+ warnings.append(traceback.format_exc())
54
+ write_result(result_path, False, None, output_files, warnings, str(exc))
55
+
56
+
57
+ def status():
58
+ return {
59
+ "version": first_success(
60
+ lambda: lux.getKeyShotVersion(),
61
+ lambda: lux.getVersion(),
62
+ lambda: lux.version(),
63
+ default=None,
64
+ ),
65
+ "isHeadless": first_success(
66
+ lambda: lux.isHeadless(),
67
+ lambda: lux.is_headless(),
68
+ default=True,
69
+ ),
70
+ "availableFunctions": sorted([name for name in dir(lux) if not name.startswith("_")]),
71
+ }
72
+
73
+
74
+ def inspect_scene():
75
+ objects = [describe_object(obj) for obj in safe_list_call("getObjects")]
76
+ cameras = [serialize_value(camera) for camera in safe_list_call("getCameras")]
77
+ model_sets = [serialize_value(model_set) for model_set in safe_list_call("getModelSets")]
78
+ environments = [serialize_value(environment) for environment in safe_list_call("getEnvironments")]
79
+ materials = []
80
+
81
+ seen = set()
82
+ for obj in objects:
83
+ material = obj.get("material")
84
+ key = json.dumps(material, sort_keys=True, default=str)
85
+ if material is not None and key not in seen:
86
+ seen.add(key)
87
+ materials.append(material)
88
+
89
+ return {
90
+ "objects": objects,
91
+ "cameras": cameras,
92
+ "materials": materials,
93
+ "modelSets": model_sets,
94
+ "environments": environments,
95
+ "externalFiles": serialize_value(first_success(lambda: lux.getExternalFiles(), default=[])),
96
+ }
97
+
98
+
99
+ def render(payload, output_files, warnings):
100
+ output_path = payload.get("outputPath") or default_output_path(payload, "render", payload.get("format") or "png")
101
+ ensure_parent(output_path)
102
+
103
+ camera = payload.get("camera")
104
+ if camera:
105
+ if hasattr(lux, "setCamera"):
106
+ call_variants(
107
+ "set camera",
108
+ lambda: lux.setCamera(camera),
109
+ lambda: lux.setCamera(str(camera)),
110
+ )
111
+ else:
112
+ warnings.append("Camera selection was requested, but lux.setCamera is not available.")
113
+
114
+ width = payload.get("width") or 1920
115
+ height = payload.get("height") or 1080
116
+ samples = payload.get("samples")
117
+ max_time = payload.get("maxTimeSeconds")
118
+
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
+ )
126
+
127
+ output_files.append(output_path)
128
+ return {"rendered": output_path, "width": width, "height": height}
129
+
130
+
131
+ def batch_render(payload, output_files, warnings):
132
+ scene_path = payload.get("scenePath")
133
+ output_dir = payload.get("outputDir")
134
+ cameras = payload.get("cameras") or []
135
+ width = payload.get("width") or 1920
136
+ height = payload.get("height") or 1080
137
+ image_format = payload.get("format") or "png"
138
+ overwrite = bool(payload.get("overwrite", False))
139
+
140
+ if not scene_path:
141
+ raise RuntimeError("scenePath is required")
142
+ if not output_dir:
143
+ raise RuntimeError("outputDir is required")
144
+ if not cameras:
145
+ raise RuntimeError("cameras must contain at least one camera name")
146
+
147
+ os.makedirs(output_dir, exist_ok=True)
148
+ rendered = []
149
+
150
+ for camera in cameras:
151
+ safe_camera = safe_filename(camera)
152
+ output_path = os.path.join(output_dir, "%s.%s" % (safe_camera, image_format))
153
+ if os.path.exists(output_path) and not overwrite:
154
+ raise RuntimeError("Output already exists and overwrite is false: %s" % output_path)
155
+
156
+ render_payload = dict(payload)
157
+ render_payload["camera"] = camera
158
+ render_payload["outputPath"] = output_path
159
+ render_payload["width"] = width
160
+ render_payload["height"] = height
161
+ render_payload["format"] = image_format
162
+
163
+ result = render(render_payload, output_files, warnings)
164
+ rendered.append({"camera": camera, "outputPath": result.get("rendered")})
165
+
166
+ return {
167
+ "scenePath": scene_path,
168
+ "outputDir": output_dir,
169
+ "cameras": cameras,
170
+ "rendered": rendered,
171
+ "width": width,
172
+ "height": height,
173
+ "format": image_format,
174
+ }
175
+
176
+
177
+ def import_model(payload, output_files, warnings):
178
+ model_path = payload.get("modelPath")
179
+ output_scene_path = payload.get("outputScenePath")
180
+ if not model_path or not os.path.exists(model_path):
181
+ raise RuntimeError("Model file not found: %s" % model_path)
182
+ if not output_scene_path:
183
+ raise RuntimeError("outputScenePath is required")
184
+
185
+ call_variants(
186
+ "import model",
187
+ lambda: lux.importFile(model_path),
188
+ lambda: lux.importFile(str(model_path)),
189
+ )
190
+ save_to(output_scene_path)
191
+ output_files.append(output_scene_path)
192
+ return {"importedModel": model_path, "savedScene": output_scene_path}
193
+
194
+
195
+ def apply_material(payload, output_files, warnings):
196
+ output_scene_path = payload.get("outputScenePath")
197
+ target = find_object(payload.get("objectName"), payload.get("objectPath"))
198
+ if target is None:
199
+ raise RuntimeError("Object not found. Provide a valid objectName or objectPath.")
200
+
201
+ material = resolve_material(payload.get("materialName"), payload.get("materialPath"), warnings)
202
+ call_variants(
203
+ "set object material",
204
+ lambda: lux.setObjectMaterial(target, material),
205
+ lambda: lux.setObjectMaterial(target, payload.get("materialName")),
206
+ lambda: lux.setObjectMaterial(target, payload.get("materialPath")),
207
+ )
208
+
209
+ save_to(output_scene_path)
210
+ output_files.append(output_scene_path)
211
+ return {
212
+ "object": describe_object(target),
213
+ "material": serialize_value(material),
214
+ "savedScene": output_scene_path,
215
+ }
216
+
217
+
218
+ def set_camera(payload, output_files, warnings):
219
+ output_scene_path = payload.get("outputScenePath")
220
+ camera_name = payload.get("cameraName") or "MCP Camera"
221
+ position = payload.get("position")
222
+ look_at = payload.get("lookAt")
223
+ up = payload.get("up") or [0, 1, 0]
224
+
225
+ if position is None or look_at is None:
226
+ raise RuntimeError("position and lookAt are required")
227
+
228
+ camera = first_success(
229
+ lambda: lux.getCamera(camera_name),
230
+ lambda: lux.newCamera(camera_name),
231
+ lambda: lux.createCamera(camera_name),
232
+ default=None,
233
+ )
234
+
235
+ if camera is None and hasattr(lux, "saveCamera"):
236
+ camera = call_variants("save camera", lambda: lux.saveCamera(camera_name))
237
+
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
+ )
256
+
257
+ save_to(output_scene_path)
258
+ output_files.append(output_scene_path)
259
+ return {
260
+ "cameraName": camera_name,
261
+ "position": position,
262
+ "lookAt": look_at,
263
+ "up": up,
264
+ "savedScene": output_scene_path,
265
+ }
266
+
267
+
268
+ def set_environment(payload, output_files, warnings):
269
+ output_scene_path = payload.get("outputScenePath")
270
+ environment_name = payload.get("environmentName")
271
+ environment_path = payload.get("environmentPath")
272
+ brightness = payload.get("brightness")
273
+
274
+ changed = False
275
+ if environment_path:
276
+ if not os.path.exists(environment_path):
277
+ raise RuntimeError("Environment file not found: %s" % environment_path)
278
+ call_variants(
279
+ "set environment file",
280
+ lambda: lux.setEnvironment(environment_path),
281
+ lambda: lux.loadEnvironment(environment_path),
282
+ lambda: lux.importEnvironment(environment_path),
283
+ )
284
+ changed = True
285
+ elif environment_name:
286
+ call_variants(
287
+ "set environment name",
288
+ lambda: lux.setEnvironment(environment_name),
289
+ lambda: lux.setEnvironmentByName(environment_name),
290
+ )
291
+ changed = True
292
+
293
+ if brightness is not None:
294
+ call_variants(
295
+ "set environment brightness",
296
+ lambda: lux.setEnvironmentBrightness(brightness),
297
+ lambda: lux.setEnvironmentPower(brightness),
298
+ )
299
+ changed = True
300
+
301
+ if not changed:
302
+ raise RuntimeError("No supported environment change was requested.")
303
+
304
+ save_to(output_scene_path)
305
+ output_files.append(output_scene_path)
306
+ return {
307
+ "environmentName": environment_name,
308
+ "environmentPath": environment_path,
309
+ "brightness": brightness,
310
+ "savedScene": output_scene_path,
311
+ }
312
+
313
+
314
+ def save_scene(payload, output_files, warnings):
315
+ output_scene_path = payload.get("outputScenePath")
316
+ save_to(output_scene_path)
317
+ output_files.append(output_scene_path)
318
+ return {"savedScene": output_scene_path}
319
+
320
+
321
+ def safe_list_call(name):
322
+ fn = getattr(lux, name, None)
323
+ if fn is None:
324
+ return []
325
+ value = fn()
326
+ if value is None:
327
+ return []
328
+ if isinstance(value, (list, tuple)):
329
+ return value
330
+ try:
331
+ return list(value)
332
+ except TypeError:
333
+ return [value]
334
+
335
+
336
+ def describe_object(obj):
337
+ return {
338
+ "name": first_success(lambda: obj.getName(), lambda: obj.name(), lambda: obj.name, default=repr(obj)),
339
+ "path": first_success(lambda: obj.getPath(), lambda: obj.path(), lambda: obj.path, default=None),
340
+ "type": first_success(lambda: obj.getType(), lambda: obj.type(), lambda: obj.type, default=type(obj).__name__),
341
+ "material": serialize_value(
342
+ first_success(lambda: obj.getMaterial(), lambda: obj.material(), lambda: obj.material, default=None)
343
+ ),
344
+ "children": len(first_success(lambda: obj.getChildren(), lambda: obj.children(), lambda: obj.children, default=[])),
345
+ }
346
+
347
+
348
+ def find_object(name, path):
349
+ for obj in safe_list_call("getObjects"):
350
+ description = describe_object(obj)
351
+ if name and description.get("name") == name:
352
+ return obj
353
+ if path and description.get("path") == path:
354
+ return obj
355
+ return None
356
+
357
+
358
+ def resolve_material(material_name, material_path, warnings):
359
+ if material_path:
360
+ if not os.path.exists(material_path):
361
+ raise RuntimeError("Material file not found: %s" % material_path)
362
+ return first_success(
363
+ lambda: lux.importMaterial(material_path),
364
+ lambda: lux.loadMaterial(material_path),
365
+ default=material_path,
366
+ )
367
+ if material_name:
368
+ return first_success(
369
+ lambda: lux.getMaterial(material_name),
370
+ lambda: lux.findMaterial(material_name),
371
+ default=material_name,
372
+ )
373
+ raise RuntimeError("materialName or materialPath is required")
374
+
375
+
376
+ def save_to(output_scene_path):
377
+ if not output_scene_path:
378
+ raise RuntimeError("outputScenePath is required")
379
+ ensure_parent(output_scene_path)
380
+ call_variants(
381
+ "save scene",
382
+ lambda: lux.saveScene(output_scene_path),
383
+ lambda: lux.saveFile(output_scene_path),
384
+ lambda: lux.save(output_scene_path),
385
+ )
386
+
387
+
388
+ def call_variants(label, *callbacks):
389
+ errors = []
390
+ for callback in callbacks:
391
+ try:
392
+ value = callback()
393
+ if value is not None:
394
+ return value
395
+ return value
396
+ except Exception as exc:
397
+ errors.append(str(exc))
398
+ raise RuntimeError("%s is unsupported or failed: %s" % (label, " | ".join(errors)))
399
+
400
+
401
+ def first_success(*callbacks, default=None):
402
+ for callback in callbacks:
403
+ try:
404
+ return callback()
405
+ except Exception:
406
+ pass
407
+ return default
408
+
409
+
410
+ def serialize_value(value):
411
+ if value is None or isinstance(value, (str, int, float, bool)):
412
+ return value
413
+ if isinstance(value, (list, tuple)):
414
+ return [serialize_value(item) for item in value]
415
+ if isinstance(value, dict):
416
+ return {str(key): serialize_value(item) for key, item in value.items()}
417
+
418
+ data = {
419
+ "repr": repr(value),
420
+ "type": type(value).__name__,
421
+ }
422
+ for attr in ("getName", "getPath", "getType"):
423
+ if hasattr(value, attr):
424
+ data[attr[3:].lower()] = first_success(lambda attr=attr: getattr(value, attr)(), default=None)
425
+ return data
426
+
427
+
428
+ def default_output_path(payload, stem, extension):
429
+ output_dir = payload.get("defaults", {}).get("outputDir") or os.getcwd()
430
+ return os.path.join(output_dir, "%s.%s" % (stem, extension))
431
+
432
+
433
+ def ensure_parent(file_path):
434
+ parent = os.path.dirname(os.path.abspath(file_path))
435
+ if parent:
436
+ os.makedirs(parent, exist_ok=True)
437
+
438
+
439
+ def safe_filename(value):
440
+ safe = "".join(ch if ch.isalnum() or ch in ("-", "_", ".") else "_" for ch in str(value))
441
+ return safe.strip("._") or "camera"
442
+
443
+
444
+ def unsupported(message):
445
+ return {"unsupported": True, "reason": message}
446
+
447
+
448
+ def write_result(result_path, ok, data, output_files, warnings, error):
449
+ ensure_parent(result_path)
450
+ with open(result_path, "w", encoding="utf-8") as handle:
451
+ json.dump(
452
+ {
453
+ "ok": ok,
454
+ "data": data,
455
+ "outputFiles": output_files,
456
+ "warnings": warnings,
457
+ "keyshotStdoutTail": "",
458
+ "error": error,
459
+ },
460
+ handle,
461
+ ensure_ascii=False,
462
+ indent=2,
463
+ default=str,
464
+ )
465
+
466
+
467
+ if __name__ == "__main__":
468
+ main()