Instant Connection for Pixel Streaming

— New Feature Automated Setup

Guide

Codex with KeyShot: Batch Product Rendering on Vagon

Use Codex with KeyShot on Vagon to batch product renders, manage material and camera variants, validate PNG outputs, and rerun selected render jobs.

On this page

Three product finishes and three camera angles give you nine images to deliver. Each needs the correct material, consistent lighting, an approved crop, and a filename that identifies the variant. Repeating those choices by hand makes revisions harder to track.

Codex can help turn that work into a repeatable batch. It prepares the job list and Python script, while KeyShot’s scripting runtime applies the scene settings and renders the images. You review the finishes and framing before expanding the run.

On Vagon Cloud Computer, you can keep Codex, KeyShot, project assets, and output inspection on the same Windows desktop. That lets you develop the automation, adjust the scene, and check the saved images in one workspace.

This guide follows one reusable bottle through three finishes and three saved cameras. We will define the nine outputs, verify one preview, build the batch runner, and check the delivery folder for missing, incorrect, or outdated renders.

Define the Nine Product Images Before Automating

Start with a delivery specification for bottle BTL-01. Every finish needs a front view, a three-quarter view, and a detail view. Keep the geometry, label placement, and lighting consistent across finishes so reviewers can compare the product variants.

Use these settings for the walkthrough:

Item

Decision

Finishes

matte_black, brushed_steel, warm_white

Saved cameras

front, three_quarter, detail

Coverage

Every finish with every camera: nine images

Preview

800 × 800 pixels, saved separately

Final output

2400 × 2400 pixels, PNG, transparent background

Filename

BTL-01_<finish>_<camera>_v01.png

These image sizes are example delivery choices. Use your actual catalog requirements when adapting the workflow. Approve a consistent crop for each camera across all three finishes; the detail view will intentionally show less of the bottle.

A filename such as BTL-01_matte_black_front_v01.png identifies one expected result. Record that same identity in the job list, render log, and output check. Keep previews outside the final folder so a quick test cannot be mistaken for a finished image.

Decide What Codex Should Add

KeyShot’s Render Dialog Queue already supports batch jobs from saved cameras, Studios, and Multi-Materials, along with output name templates. For a small, occasional batch, those controls may cover your needs.

KeyShot Render dialog showing queued image jobs and controls for adding cameras, Studios, and Multi-Materials.

Source: KeyShot documentation

Here, we will use Codex to build one explicit job list that also drives output checks and selective retries. That becomes useful when a finish changes, one camera needs reframing, or delivery includes only selected combinations. The list defines what should exist; the saved images show whether the batch actually delivered it.

Set Up KeyShot and Codex on the Same Vagon Desktop

Prepare a Windows Vagon Cloud Computer for this workflow. Choose resources against your KeyShot release’s system requirements and the scene you intend to render. If you plan to use GPU mode, check the selected GPU and driver against those requirements before committing to the full batch.

Open Both Tools in the Working Environment

Use Vagon’s application selection for available software, and install your required KeyShot release through its official installer if needed. Activate KeyShot with your own license and confirm it works on the selected computer.

Install Codex CLI from a terminal inside the Vagon desktop, then sign in with your own OpenAI account. This walkthrough uses the CLI to work with project files and scripts. KeyShot will execute the rendering code through its own scripting interface.

Open KeyShot, record its exact version, and confirm that Window > Scripting Console is available. Keep that version with the project notes so Codex can use the corresponding API documentation when preparing the runner.

Bring the Scene and Its Dependencies

Transfer the bottle project through Vagon Files, including its textures, label artwork, and HDRI environment. Open the scene on Vagon and resolve missing resources before scripting anything. Check that the bottle’s materials and lighting match your approved reference.

KeyShot Resolve Missing Resources dialog listing two missing texture files and a Search Folder button.

Source: KeyShot documentation

Create a project folder such as C:\Projects\bottle-batch:

bottle-batch/
    scenes/
        bottle_master.bip
        bottle_batch.bip
    assets/
    scripts/
    config/
    renders/
        preview/
        final/
    logs

bottle-batch/
    scenes/
        bottle_master.bip
        bottle_batch.bip
    assets/
    scripts/
    config/
    renders/
        preview/
        final/
    logs

bottle-batch/
    scenes/
        bottle_master.bip
        bottle_batch.bip
    assets/
    scripts/
    config/
    renders/
        preview/
        final/
    logs

Keep bottle_master.bip as the original and save the working scene as bottle_batch.bip. Store transferred dependencies in assets and resolve their paths in the working scene. Reserve config for the job manifest and scripts for the automation.

In PowerShell, start Codex from the project root:

cd C:\Projects\bottle-batch
codex
cd C:\Projects\bottle-batch
codex
cd C:\Projects\bottle-batch
codex

The workspace is ready when KeyShot opens the working scene correctly and Codex can inspect the project folder. Next, give the finishes and cameras stable names inside KeyShot.

Prepare Finish Studios and Saved Cameras in KeyShot

Open bottle_batch.bip and prepare the variants before asking Codex to automate them. Use the same bottle geometry for all nine images, with the cap and label kept consistent.

Put the Three Finishes in One Multi-Material

Select the bottle body’s material and convert it to a Multi-Material. Name the container bottle_body and add three sub-materials named matte_black, brushed_steel, and warm_white.

KeyShot Material panel showing a Multi-Material containing several named gemstone sub-materials.

Source: KeyShot documentation

Review each finish on the actual bottle. Check roughness, texture scale, brushing direction, and label appearance. A finish name identifies the selection; you still need to approve its appearance under the intended lighting.

Open Window > Studios and create three Studios using those same finish names. In each Studio:

  1. Enable Multi-Materials and select the corresponding sub-material for bottle_body.

  2. Assign the same approved environment and image style.

  3. Leave Camera unchecked so the job list can choose the view independently.

  4. Keep the same model visibility across all three finishes.

Check the component selections explicitly. New Studios include Camera and Environment by default; creating a Studio while a finish is visible does not automatically configure its Multi-Material selection.

KeyShot Studios panel with Camera, Environment, Image Style, Model Sets, and Multi-Materials controls for a toy vehicle.

Source: KeyShot documentation

Save the Three Views

In the Camera tab, compose and save views named front, three_quarter, and detail. Set the output aspect ratio to square while checking the framing. Use the front and three-quarter views for the complete product, and the detail view for a specific feature such as the cap or surface finish.

KeyShot Camera tab with a saved camera list, position and orientation controls, and perspective lens settings.

Source: KeyShot documentation

Switch through every finish with each camera. Confirm that the label stays in place, reflections remain useful, and the white finish separates clearly from the review background.

Save the working scene, reopen it, and check the named selections again. The runner will activate the finish Studio first, then select the saved camera explicitly. This order also prevents an accidentally stored Studio camera from determining the rendered view.

Ask Codex to Audit the Scene and Build a Job Manifest

Give Codex an inventory from the open KeyShot scene before asking it to generate render jobs. A directory listing cannot tell it which cameras or Studios are saved inside bottle_batch.bip.

With that working scene open, run this code in KeyShot’s Scripting Console. It uses the documented scene inspection functions and writes a report without changing the scene:

import json
from pathlib import Path
import lux

root = Path(r"C:\Projects\bottle-batch")
report = {
    "scene": lux.getSceneInfo(),
    "cameras": list(lux.getCameras()),
    "studios": {
        studio.getName(): {
            "materials": studio.getMultiMaterials(),
            "materials_enabled": studio.isMultiMaterialsEnabled(),
            "environment": studio.getEnvironment(),
            "image_style": studio.getImageStyle(),
        }
        for studio in lux.getStudios(instances=True)
    },
}
(root / "logs").mkdir(exist_ok=True)
(root / "logs" / "scene_inventory.json").write_text(
    json.dumps(report, indent=2), encoding="utf-8"
)
import json
from pathlib import Path
import lux

root = Path(r"C:\Projects\bottle-batch")
report = {
    "scene": lux.getSceneInfo(),
    "cameras": list(lux.getCameras()),
    "studios": {
        studio.getName(): {
            "materials": studio.getMultiMaterials(),
            "materials_enabled": studio.isMultiMaterialsEnabled(),
            "environment": studio.getEnvironment(),
            "image_style": studio.getImageStyle(),
        }
        for studio in lux.getStudios(instances=True)
    },
}
(root / "logs").mkdir(exist_ok=True)
(root / "logs" / "scene_inventory.json").write_text(
    json.dumps(report, indent=2), encoding="utf-8"
)
import json
from pathlib import Path
import lux

root = Path(r"C:\Projects\bottle-batch")
report = {
    "scene": lux.getSceneInfo(),
    "cameras": list(lux.getCameras()),
    "studios": {
        studio.getName(): {
            "materials": studio.getMultiMaterials(),
            "materials_enabled": studio.isMultiMaterialsEnabled(),
            "environment": studio.getEnvironment(),
            "image_style": studio.getImageStyle(),
        }
        for studio in lux.getStudios(instances=True)
    },
}
(root / "logs").mkdir(exist_ok=True)
(root / "logs" / "scene_inventory.json").write_text(
    json.dumps(report, indent=2), encoding="utf-8"
)

Check the report’s scene identity against the file you intended to open. Confirm that all three cameras and finish Studios appear, that each Studio enables its intended material selection, and that their environment and image style agree. Correct missing or unexpected selections in KeyShot, save, and regenerate the report.

Turn the Inventory into Explicit Jobs

In Codex, use a focused request:

Read logs/scene_inventory.json. Compare its scene, cameras, and Studios with our BTL-01 delivery specification. Report missing names or incorrect bottle_body material mappings. Do not render or edit the scene. Prepare config/jobs.json with one job per finish-camera pair and unique IDs. Validate filenames derived as BTL-01__v01.png and reject duplicate output paths. Use project-relative paths.

Use this complete manifest structure for the example, after confirming the inventory matches:

{
  "scene": "scenes/bottle_batch.bip",
  "output_dir": "renders/final",
  "width": 2400,
  "height": 2400,
  "samples": 256,
  "alpha": true,
  "jobs": [
    {"id": "matte_black_front", "studio": "matte_black", "camera": "front"},
    {"id": "matte_black_three_quarter", "studio": "matte_black", "camera": "three_quarter"},
    {"id": "matte_black_detail", "studio": "matte_black", "camera": "detail"},
    {"id": "brushed_steel_front", "studio": "brushed_steel", "camera": "front"},
    {"id": "brushed_steel_three_quarter", "studio": "brushed_steel", "camera": "three_quarter"},
    {"id": "brushed_steel_detail", "studio": "brushed_steel", "camera": "detail"},
    {"id": "warm_white_front", "studio": "warm_white", "camera": "front"},
    {"id": "warm_white_three_quarter", "studio": "warm_white", "camera": "three_quarter"},
    {"id": "warm_white_detail", "studio": "warm_white", "camera": "detail"}
  ]
}
{
  "scene": "scenes/bottle_batch.bip",
  "output_dir": "renders/final",
  "width": 2400,
  "height": 2400,
  "samples": 256,
  "alpha": true,
  "jobs": [
    {"id": "matte_black_front", "studio": "matte_black", "camera": "front"},
    {"id": "matte_black_three_quarter", "studio": "matte_black", "camera": "three_quarter"},
    {"id": "matte_black_detail", "studio": "matte_black", "camera": "detail"},
    {"id": "brushed_steel_front", "studio": "brushed_steel", "camera": "front"},
    {"id": "brushed_steel_three_quarter", "studio": "brushed_steel", "camera": "three_quarter"},
    {"id": "brushed_steel_detail", "studio": "brushed_steel", "camera": "detail"},
    {"id": "warm_white_front", "studio": "warm_white", "camera": "front"},
    {"id": "warm_white_three_quarter", "studio": "warm_white", "camera": "three_quarter"},
    {"id": "warm_white_detail", "studio": "warm_white", "camera": "detail"}
  ]
}
{
  "scene": "scenes/bottle_batch.bip",
  "output_dir": "renders/final",
  "width": 2400,
  "height": 2400,
  "samples": 256,
  "alpha": true,
  "jobs": [
    {"id": "matte_black_front", "studio": "matte_black", "camera": "front"},
    {"id": "matte_black_three_quarter", "studio": "matte_black", "camera": "three_quarter"},
    {"id": "matte_black_detail", "studio": "matte_black", "camera": "detail"},
    {"id": "brushed_steel_front", "studio": "brushed_steel", "camera": "front"},
    {"id": "brushed_steel_three_quarter", "studio": "brushed_steel", "camera": "three_quarter"},
    {"id": "brushed_steel_detail", "studio": "brushed_steel", "camera": "detail"},
    {"id": "warm_white_front", "studio": "warm_white", "camera": "front"},
    {"id": "warm_white_three_quarter", "studio": "warm_white", "camera": "three_quarter"},
    {"id": "warm_white_detail", "studio": "warm_white", "camera": "detail"}
  ]
}

Derive each filename as BTL-01_<id>_v01.png. The sample count is a starting value for this example; adjust it after inspecting noise in representative renders. Resolution and sample count alone do not establish acceptable image quality.

KeyShot Render Options dialog with Maximum Samples selected and the sample count set to 128.

Source: KeyShot documentation

Ask Codex to validate all nine combinations and the derived filenames before execution. Keep the manifest as the agreed specification, and have the runner check it against the loaded scene again when it starts. An inventory describes the scene at the time it was collected; later edits can make it stale.

Render One Preview Through the KeyShot Runtime

Start with matte_black_front. This single job checks whether the saved scene, Studio, camera, render options, and output path work together before you spend time on nine final images.

Save any scene edits first. The following script reopens the file specified in config/jobs.json, so it uses the saved version of bottle_batch.bip. Save the code as scripts/preview.py, then load and run it through KeyShot’s Scripting Console > Scripts tab. It targets the documented 2026.1 API; verify it in your installed release. Check the inventory first: the scene guard requires an absolute .bip path in the values returned by getSceneInfo(). It stops if the path is missing or differs from the requested file. If your build reports only a basename, establish a supported full-path check for that build before proceeding.

import json
from pathlib import Path
import lux

root = Path(r"C:\Projects\bottle-batch")
config = json.loads((root / "config/jobs.json").read_text(encoding="utf-8"))
job = next(j for j in config["jobs"] if j["id"] == "matte_black_front")
scene = root / config["scene"]
output = root / "renders/preview" / f"BTL-01_{job['id']}_v01.png"
if not scene.is_file():
    raise FileNotFoundError(scene)
if output.exists():
    raise FileExistsError(f"Move the previous preview before rerunning: {output}")
output.parent.mkdir(parents=True, exist_ok=True)

lux.openFile(str(scene), dontAsk=False)
scene_info = lux.getSceneInfo()
loaded_paths = [Path(value) for value in scene_info.values()
                if isinstance(value, str) and Path(value).is_absolute()
                and Path(value).suffix.lower() == ".bip"]
if not loaded_paths or any(not path.samefile(scene) for path in loaded_paths):
    raise RuntimeError(
        f"Cannot verify the loaded .bip path against {scene}: {scene_info}"
    )
if job["studio"] not in list(lux.getStudios()):
    raise ValueError("Required Studio is missing")
if job["camera"] not in list(lux.getCameras()):
    raise ValueError("Required camera is missing")
lux.setActiveStudio(job["studio"])
lux.setCamera(job["camera"])
opts = lux.getRenderOptions(defaults=True)
opts.setAddToQueue(False)
opts.setBackgroundRendering(False)
opts.setMaxSamplesRendering(32)
opts.setOutputAlphaChannel(config["alpha"])
lux.renderImage(path=str(output), width=800, height=800, opts=opts)
if not output.is_file() or output.stat().st_size == 0:
    raise RuntimeError("Preview output is missing or empty")

record = {"job": job, "output": str(output), "width": 800,
          "height": 800, "samples": 32, "status": "written",
          "scene_info": scene_info, "render_engine": lux.getRenderEngine()}
(root / "logs").mkdir(exist_ok=True)
(root / "logs/preview.json").write_text(
    json.dumps(record, indent=2), encoding="utf-8"
)
print(f"Preview written: {output}")
import json
from pathlib import Path
import lux

root = Path(r"C:\Projects\bottle-batch")
config = json.loads((root / "config/jobs.json").read_text(encoding="utf-8"))
job = next(j for j in config["jobs"] if j["id"] == "matte_black_front")
scene = root / config["scene"]
output = root / "renders/preview" / f"BTL-01_{job['id']}_v01.png"
if not scene.is_file():
    raise FileNotFoundError(scene)
if output.exists():
    raise FileExistsError(f"Move the previous preview before rerunning: {output}")
output.parent.mkdir(parents=True, exist_ok=True)

lux.openFile(str(scene), dontAsk=False)
scene_info = lux.getSceneInfo()
loaded_paths = [Path(value) for value in scene_info.values()
                if isinstance(value, str) and Path(value).is_absolute()
                and Path(value).suffix.lower() == ".bip"]
if not loaded_paths or any(not path.samefile(scene) for path in loaded_paths):
    raise RuntimeError(
        f"Cannot verify the loaded .bip path against {scene}: {scene_info}"
    )
if job["studio"] not in list(lux.getStudios()):
    raise ValueError("Required Studio is missing")
if job["camera"] not in list(lux.getCameras()):
    raise ValueError("Required camera is missing")
lux.setActiveStudio(job["studio"])
lux.setCamera(job["camera"])
opts = lux.getRenderOptions(defaults=True)
opts.setAddToQueue(False)
opts.setBackgroundRendering(False)
opts.setMaxSamplesRendering(32)
opts.setOutputAlphaChannel(config["alpha"])
lux.renderImage(path=str(output), width=800, height=800, opts=opts)
if not output.is_file() or output.stat().st_size == 0:
    raise RuntimeError("Preview output is missing or empty")

record = {"job": job, "output": str(output), "width": 800,
          "height": 800, "samples": 32, "status": "written",
          "scene_info": scene_info, "render_engine": lux.getRenderEngine()}
(root / "logs").mkdir(exist_ok=True)
(root / "logs/preview.json").write_text(
    json.dumps(record, indent=2), encoding="utf-8"
)
print(f"Preview written: {output}")
import json
from pathlib import Path
import lux

root = Path(r"C:\Projects\bottle-batch")
config = json.loads((root / "config/jobs.json").read_text(encoding="utf-8"))
job = next(j for j in config["jobs"] if j["id"] == "matte_black_front")
scene = root / config["scene"]
output = root / "renders/preview" / f"BTL-01_{job['id']}_v01.png"
if not scene.is_file():
    raise FileNotFoundError(scene)
if output.exists():
    raise FileExistsError(f"Move the previous preview before rerunning: {output}")
output.parent.mkdir(parents=True, exist_ok=True)

lux.openFile(str(scene), dontAsk=False)
scene_info = lux.getSceneInfo()
loaded_paths = [Path(value) for value in scene_info.values()
                if isinstance(value, str) and Path(value).is_absolute()
                and Path(value).suffix.lower() == ".bip"]
if not loaded_paths or any(not path.samefile(scene) for path in loaded_paths):
    raise RuntimeError(
        f"Cannot verify the loaded .bip path against {scene}: {scene_info}"
    )
if job["studio"] not in list(lux.getStudios()):
    raise ValueError("Required Studio is missing")
if job["camera"] not in list(lux.getCameras()):
    raise ValueError("Required camera is missing")
lux.setActiveStudio(job["studio"])
lux.setCamera(job["camera"])
opts = lux.getRenderOptions(defaults=True)
opts.setAddToQueue(False)
opts.setBackgroundRendering(False)
opts.setMaxSamplesRendering(32)
opts.setOutputAlphaChannel(config["alpha"])
lux.renderImage(path=str(output), width=800, height=800, opts=opts)
if not output.is_file() or output.stat().st_size == 0:
    raise RuntimeError("Preview output is missing or empty")

record = {"job": job, "output": str(output), "width": 800,
          "height": 800, "samples": 32, "status": "written",
          "scene_info": scene_info, "render_engine": lux.getRenderEngine()}
(root / "logs").mkdir(exist_ok=True)
(root / "logs/preview.json").write_text(
    json.dumps(record, indent=2), encoding="utf-8"
)
print(f"Preview written: {output}")

The render options explicitly disable queue submission and external background rendering. This keeps the first test in a direct execution path. The final manifest remains unchanged: this preview uses 800 × 800 pixels and 32 samples instead of the final settings.

Inspect the Saved Image

Open the PNG from renders/preview, rather than judging only the KeyShot viewport. Check that it shows the matte black bottle from the front, with the intended label placement and crop. Inspect transparency over both light and dark backgrounds, especially around the cap and silhouette.

Blue-to-orange sphere composited on black with visible jagged edge artifacts along the blue silhouette.

Source: KeyShot documentation

The script’s written status establishes only that a nonempty file appeared. Confirm its dimensions and that it opens correctly. At this sample count, noise may remain; use the preview to approve scene selection and composition, then assess final quality at the delivery settings.

If the image is wrong, correct the Studio or saved camera, save the scene, and repeat this job. Move the previous preview aside first. Expand to the batch only after the saved result matches the intended variant.

Expand the Verified Job into a Repeatable Batch

Once the preview matches the intended view, use the same Studio-then-camera sequence for the final jobs. Ask Codex to extend the verified operations into a standalone scripts/batch_render.py, keeping config/jobs.json as the source of render settings.

The example below checks the full nine-job specification before rendering. It rejects duplicate IDs, incorrect finish-camera mappings, missing scene selections, and inconsistent Studio environments or image styles. It also checks that each finish Studio selects the expected bottle_body sub-material.

Save scene edits before running it through KeyShot’s Scripts tab. This script clears the current scene and opens the saved manifest scene. Like the preview, its KeyShot calls target the 2026.1 scripting API; test them in your installed build before production use.

import hashlib
import json
from pathlib import Path
import uuid
import lux

root = Path(r"C:\Projects\bottle-batch").resolve()
SELECT_IDS = set()  # Empty: all jobs. Otherwise: exact job IDs to rerender.
config = json.loads((root / "config/jobs.json").read_text(encoding="utf-8"))

def project_path(value):
    path = (root / value).resolve()
    if Path(value).is_absolute() or not path.is_relative_to(root):
        raise ValueError(f"Expected a project-relative path: {value}")
    return path

scene = project_path(config["scene"])
output_root = project_path(config["output_dir"])
if not scene.is_file():
    raise FileNotFoundError(scene)
for key in ("width", "height", "samples"):
    if type(config[key]) is not int or config[key] < 1:
        raise ValueError(f"Invalid {key}")
if type(config["alpha"]) is not bool:
    raise ValueError("alpha must be true or false")
finishes = {"matte_black", "brushed_steel", "warm_white"}
cameras = {"front", "three_quarter", "detail"}
expected = {f"{s}_{c}": (s, c) for s in finishes for c in cameras}
jobs = config["jobs"]
ids = [j["id"] for j in jobs]
if len(ids) != len(set(ids)) or set(ids) != set(expected):
    raise ValueError("Expected nine unique finish-camera jobs")
for job in jobs:
    if (job["studio"], job["camera"]) != expected[job["id"]]:
        raise ValueError(f"Incorrect mapping: {job['id']}")
if SELECT_IDS - set(ids):
    raise ValueError("Unknown retry IDs")
selected = [j for j in jobs if not SELECT_IDS or j["id"] in SELECT_IDS]

lux.newScene(dontAsk=False)
lux.openFile(str(scene), dontAsk=False)
scene_info = lux.getSceneInfo()
loaded_paths = [Path(value) for value in scene_info.values()
                if isinstance(value, str) and Path(value).is_absolute()
                and Path(value).suffix.lower() == ".bip"]
if not loaded_paths or any(not path.samefile(scene) for path in loaded_paths):
    raise RuntimeError(
        f"Cannot verify the loaded .bip path against {scene}: {scene_info}"
    )
if not lux.hasContents():
    raise RuntimeError("The saved scene did not load content")
if not finishes <= set(lux.getStudios()) or not cameras <= set(lux.getCameras()):
    raise ValueError("Required Studios or cameras are missing")
styles = set()
for finish in finishes:
    studio = lux.getStudio(finish)
    if (not studio.isMultiMaterialsEnabled()
            or dict(studio.getMultiMaterials()).get("bottle_body") != finish):
        raise ValueError(f"Incorrect material selection: {finish}")
    styles.add((studio.getEnvironment(), studio.getImageStyle()))
if len(styles) != 1 or any(v is None for v in next(iter(styles))):
    raise ValueError("Assign the same environment and image style to all Studios")

run_id = uuid.uuid4().hex
run_dir = output_root / run_id
run_dir.mkdir(parents=True, exist_ok=False)
log_path = root / "logs" / f"batch_{run_id}.json"
log_path.parent.mkdir(exist_ok=True)
with scene.open("rb") as handle:
    scene_hash = hashlib.file_digest(handle, "sha256").hexdigest()
report = {"run_id": run_id, "scene_sha256": scene_hash,
          "keyshot_version": lux.getKeyShotVersion(),
          "scene_info": scene_info,
          "config": config, "selected_ids": [j["id"] for j in selected],
          "results": []}

def save_report():
    payload = json.dumps(report, indent=2)
    temporary = log_path.with_suffix(".json.tmp")
    temporary.write_text(payload, encoding="utf-8")
    temporary.replace(log_path)

save_report()
for job in selected:
    output = run_dir / f"BTL-01_{job['id']}_v01.png"
    result = {"job": job, "output": str(output), "status": "started"}
    report["results"].append(result)
    save_report()
    try:
        lux.setActiveStudio(job["studio"])
        lux.setCamera(job["camera"])
        opts = lux.getRenderOptions(defaults=True)
        opts.setAddToQueue(False)
        opts.setBackgroundRendering(False)
        opts.setMaxSamplesRendering(config["samples"])
        opts.setOutputAlphaChannel(config["alpha"])
        result["render_options"] = opts.getDict()
        result["render_engine"] = lux.getRenderEngine()
        save_report()
        lux.renderImage(path=str(output), width=config["width"],
                        height=config["height"], opts=opts)
        if not output.is_file() or output.stat().st_size == 0:
            raise RuntimeError("Missing or empty output")
        result["status"] = "written"
    except Exception as error:
        result.update(status="failed", error=str(error))
        raise
    finally:
        save_report()
print(f"Batch written. Inspect images and report: {log_path}")
import hashlib
import json
from pathlib import Path
import uuid
import lux

root = Path(r"C:\Projects\bottle-batch").resolve()
SELECT_IDS = set()  # Empty: all jobs. Otherwise: exact job IDs to rerender.
config = json.loads((root / "config/jobs.json").read_text(encoding="utf-8"))

def project_path(value):
    path = (root / value).resolve()
    if Path(value).is_absolute() or not path.is_relative_to(root):
        raise ValueError(f"Expected a project-relative path: {value}")
    return path

scene = project_path(config["scene"])
output_root = project_path(config["output_dir"])
if not scene.is_file():
    raise FileNotFoundError(scene)
for key in ("width", "height", "samples"):
    if type(config[key]) is not int or config[key] < 1:
        raise ValueError(f"Invalid {key}")
if type(config["alpha"]) is not bool:
    raise ValueError("alpha must be true or false")
finishes = {"matte_black", "brushed_steel", "warm_white"}
cameras = {"front", "three_quarter", "detail"}
expected = {f"{s}_{c}": (s, c) for s in finishes for c in cameras}
jobs = config["jobs"]
ids = [j["id"] for j in jobs]
if len(ids) != len(set(ids)) or set(ids) != set(expected):
    raise ValueError("Expected nine unique finish-camera jobs")
for job in jobs:
    if (job["studio"], job["camera"]) != expected[job["id"]]:
        raise ValueError(f"Incorrect mapping: {job['id']}")
if SELECT_IDS - set(ids):
    raise ValueError("Unknown retry IDs")
selected = [j for j in jobs if not SELECT_IDS or j["id"] in SELECT_IDS]

lux.newScene(dontAsk=False)
lux.openFile(str(scene), dontAsk=False)
scene_info = lux.getSceneInfo()
loaded_paths = [Path(value) for value in scene_info.values()
                if isinstance(value, str) and Path(value).is_absolute()
                and Path(value).suffix.lower() == ".bip"]
if not loaded_paths or any(not path.samefile(scene) for path in loaded_paths):
    raise RuntimeError(
        f"Cannot verify the loaded .bip path against {scene}: {scene_info}"
    )
if not lux.hasContents():
    raise RuntimeError("The saved scene did not load content")
if not finishes <= set(lux.getStudios()) or not cameras <= set(lux.getCameras()):
    raise ValueError("Required Studios or cameras are missing")
styles = set()
for finish in finishes:
    studio = lux.getStudio(finish)
    if (not studio.isMultiMaterialsEnabled()
            or dict(studio.getMultiMaterials()).get("bottle_body") != finish):
        raise ValueError(f"Incorrect material selection: {finish}")
    styles.add((studio.getEnvironment(), studio.getImageStyle()))
if len(styles) != 1 or any(v is None for v in next(iter(styles))):
    raise ValueError("Assign the same environment and image style to all Studios")

run_id = uuid.uuid4().hex
run_dir = output_root / run_id
run_dir.mkdir(parents=True, exist_ok=False)
log_path = root / "logs" / f"batch_{run_id}.json"
log_path.parent.mkdir(exist_ok=True)
with scene.open("rb") as handle:
    scene_hash = hashlib.file_digest(handle, "sha256").hexdigest()
report = {"run_id": run_id, "scene_sha256": scene_hash,
          "keyshot_version": lux.getKeyShotVersion(),
          "scene_info": scene_info,
          "config": config, "selected_ids": [j["id"] for j in selected],
          "results": []}

def save_report():
    payload = json.dumps(report, indent=2)
    temporary = log_path.with_suffix(".json.tmp")
    temporary.write_text(payload, encoding="utf-8")
    temporary.replace(log_path)

save_report()
for job in selected:
    output = run_dir / f"BTL-01_{job['id']}_v01.png"
    result = {"job": job, "output": str(output), "status": "started"}
    report["results"].append(result)
    save_report()
    try:
        lux.setActiveStudio(job["studio"])
        lux.setCamera(job["camera"])
        opts = lux.getRenderOptions(defaults=True)
        opts.setAddToQueue(False)
        opts.setBackgroundRendering(False)
        opts.setMaxSamplesRendering(config["samples"])
        opts.setOutputAlphaChannel(config["alpha"])
        result["render_options"] = opts.getDict()
        result["render_engine"] = lux.getRenderEngine()
        save_report()
        lux.renderImage(path=str(output), width=config["width"],
                        height=config["height"], opts=opts)
        if not output.is_file() or output.stat().st_size == 0:
            raise RuntimeError("Missing or empty output")
        result["status"] = "written"
    except Exception as error:
        result.update(status="failed", error=str(error))
        raise
    finally:
        save_report()
print(f"Batch written. Inspect images and report: {log_path}")
import hashlib
import json
from pathlib import Path
import uuid
import lux

root = Path(r"C:\Projects\bottle-batch").resolve()
SELECT_IDS = set()  # Empty: all jobs. Otherwise: exact job IDs to rerender.
config = json.loads((root / "config/jobs.json").read_text(encoding="utf-8"))

def project_path(value):
    path = (root / value).resolve()
    if Path(value).is_absolute() or not path.is_relative_to(root):
        raise ValueError(f"Expected a project-relative path: {value}")
    return path

scene = project_path(config["scene"])
output_root = project_path(config["output_dir"])
if not scene.is_file():
    raise FileNotFoundError(scene)
for key in ("width", "height", "samples"):
    if type(config[key]) is not int or config[key] < 1:
        raise ValueError(f"Invalid {key}")
if type(config["alpha"]) is not bool:
    raise ValueError("alpha must be true or false")
finishes = {"matte_black", "brushed_steel", "warm_white"}
cameras = {"front", "three_quarter", "detail"}
expected = {f"{s}_{c}": (s, c) for s in finishes for c in cameras}
jobs = config["jobs"]
ids = [j["id"] for j in jobs]
if len(ids) != len(set(ids)) or set(ids) != set(expected):
    raise ValueError("Expected nine unique finish-camera jobs")
for job in jobs:
    if (job["studio"], job["camera"]) != expected[job["id"]]:
        raise ValueError(f"Incorrect mapping: {job['id']}")
if SELECT_IDS - set(ids):
    raise ValueError("Unknown retry IDs")
selected = [j for j in jobs if not SELECT_IDS or j["id"] in SELECT_IDS]

lux.newScene(dontAsk=False)
lux.openFile(str(scene), dontAsk=False)
scene_info = lux.getSceneInfo()
loaded_paths = [Path(value) for value in scene_info.values()
                if isinstance(value, str) and Path(value).is_absolute()
                and Path(value).suffix.lower() == ".bip"]
if not loaded_paths or any(not path.samefile(scene) for path in loaded_paths):
    raise RuntimeError(
        f"Cannot verify the loaded .bip path against {scene}: {scene_info}"
    )
if not lux.hasContents():
    raise RuntimeError("The saved scene did not load content")
if not finishes <= set(lux.getStudios()) or not cameras <= set(lux.getCameras()):
    raise ValueError("Required Studios or cameras are missing")
styles = set()
for finish in finishes:
    studio = lux.getStudio(finish)
    if (not studio.isMultiMaterialsEnabled()
            or dict(studio.getMultiMaterials()).get("bottle_body") != finish):
        raise ValueError(f"Incorrect material selection: {finish}")
    styles.add((studio.getEnvironment(), studio.getImageStyle()))
if len(styles) != 1 or any(v is None for v in next(iter(styles))):
    raise ValueError("Assign the same environment and image style to all Studios")

run_id = uuid.uuid4().hex
run_dir = output_root / run_id
run_dir.mkdir(parents=True, exist_ok=False)
log_path = root / "logs" / f"batch_{run_id}.json"
log_path.parent.mkdir(exist_ok=True)
with scene.open("rb") as handle:
    scene_hash = hashlib.file_digest(handle, "sha256").hexdigest()
report = {"run_id": run_id, "scene_sha256": scene_hash,
          "keyshot_version": lux.getKeyShotVersion(),
          "scene_info": scene_info,
          "config": config, "selected_ids": [j["id"] for j in selected],
          "results": []}

def save_report():
    payload = json.dumps(report, indent=2)
    temporary = log_path.with_suffix(".json.tmp")
    temporary.write_text(payload, encoding="utf-8")
    temporary.replace(log_path)

save_report()
for job in selected:
    output = run_dir / f"BTL-01_{job['id']}_v01.png"
    result = {"job": job, "output": str(output), "status": "started"}
    report["results"].append(result)
    save_report()
    try:
        lux.setActiveStudio(job["studio"])
        lux.setCamera(job["camera"])
        opts = lux.getRenderOptions(defaults=True)
        opts.setAddToQueue(False)
        opts.setBackgroundRendering(False)
        opts.setMaxSamplesRendering(config["samples"])
        opts.setOutputAlphaChannel(config["alpha"])
        result["render_options"] = opts.getDict()
        result["render_engine"] = lux.getRenderEngine()
        save_report()
        lux.renderImage(path=str(output), width=config["width"],
                        height=config["height"], opts=opts)
        if not output.is_file() or output.stat().st_size == 0:
            raise RuntimeError("Missing or empty output")
        result["status"] = "written"
    except Exception as error:
        result.update(status="failed", error=str(error))
        raise
    finally:
        save_report()
print(f"Batch written. Inspect images and report: {log_path}")

Keep Each Run Identifiable

Each execution creates a new folder beneath renders/final and a matching report in logs. The image basenames retain the agreed naming pattern. A separate run folder prevents an older render from satisfying the current run’s file check and keeps previous outputs available for comparison.

The report stores the manifest, selected jobs, loaded-scene information, saved scene hash, KeyShot version, render options, and the CPU/GPU engine reported for each job. The scene hash identifies the .bip file; it does not fingerprint external textures or HDRIs. Preserve those dependencies with the project and record changes when they affect a rerun.

With SELECT_IDS empty, the script renders all nine jobs sequentially. For a selective retry, enter exact IDs, such as {"warm_white_detail"}. The full manifest remains intact, and the report records which subset ran. No existing image is automatically accepted as reusable.

Treat an Interrupted Batch as Incomplete

The script saves a started entry before each render and changes it to written only after finding a nonempty output. Report updates are written to a sibling temporary file and then replace the previous JSON, keeping the last complete report available if a temporary-file write is interrupted. It stops on the first exception and records the error. If KeyShot closes unexpectedly, a job may remain started; later jobs may have no result entry.

Compare the report with the selected job IDs to identify unfinished work. Use the validation steps below before choosing failed or incorrect jobs for another run. Previous run folders retain the successful images.

Check Every Render Against Its Product Variant

Validate one run at a time, using its logs/batch_<run_id>.json report. Read the configuration snapshot stored in that report so a later manifest edit cannot change the acceptance criteria for an earlier batch.

Check Files Against the Selected Jobs

Ask Codex to inspect the exact output paths recorded for that run:

Compare selected_ids with results in this batch report. Flag missing entries and any status other than written. Decode every recorded PNG fully, check its dimensions against the report’s config, and inspect its alpha channel for background transparency. Report unreadable, missing, empty, unexpected, or incorrectly sized files. Save logs/validation_<run_id>.json. Do not modify the renders or change their execution statuses.

Have Codex use an available image-reading tool and identify it in the validation report. Checking the extension, file size, or PNG header alone does not establish that the complete image decodes correctly.

For a full run, expect nine unique job IDs and nine readable 2400 × 2400 images. A selective retry should match only its selected IDs. Check for transparent background pixels as well as visible product pixels; an alpha channel can exist while remaining entirely opaque or transparent.

Review the Finish-Camera Matrix Visually

Create a labeled contact sheet from the validated files, with finishes in rows and cameras in columns. Use the recorded paths, and label missing or invalid results explicitly. This makes a repeated camera or an unchanged finish easier to spot.

Compare every cell with its intended variant. The front views should share framing, the three-quarter views should reveal the same side, and the detail views should show the same feature. Check that the label, cap, and bottle proportions remain consistent while the body finish changes.

Then open each image at full resolution. Inspect label edges, brushed-metal direction, highlight noise, and the silhouette over light and dark backgrounds. A thumbnail can hide clipped edges and rough transparency.

Record visual approval separately from file validation. Note each rejected job ID and the specific correction it needs. If several finishes look identical, inspect their Studio material selections before increasing samples or rerendering the entire batch.

Rerun Failed Jobs and Package the Product Images

Choose retries according to what changed. A reframed detail camera affects that view across all three finishes. A corrected warm_white material affects all three cameras for that finish. Changes to the shared environment or label artwork can invalidate the full set.

Save the corrected scene, refresh the inventory, and put every affected job ID in SELECT_IDS. Run the same batch script, then validate and review the new outputs. Reset SELECT_IDS to an empty set before the next complete batch.

Assemble One Approved Delivery Set

Copy the accepted images into a separate delivery/images folder. Select each file explicitly from its approved run; avoid collecting every PNG beneath renders/final, where earlier versions remain available.

Create delivery/delivery_manifest.csv with one row per delivered image: job ID, filename, source run ID, source path, file SHA-256, and approval note. Check that it contains all nine expected jobs exactly once. This preserves the origin of a delivery assembled from an initial batch and later retries.

Retain the working scene, dependencies, job manifest, scripts, run reports, and validation records in the project archive. Reopen the archived scene to confirm its assets resolve. Share only assets you are authorized to redistribute.

Transfer the delivery package through Vagon Files and open representative downloaded images to check the transferred copies. You now have the catalog images and the project records needed to handle the next product revision.

FAQs

Do I Need an MCP Server to Use Codex with KeyShot?

This workflow uses project files and KeyShot scripts, so it does not require an MCP server. Codex prepares the automation, and you run it through KeyShot’s scripting interface. Any additional integration needs its own installation and compatibility checks.

Can I Run These Scripts with Ordinary Python?

The render scripts need KeyShot’s lux module and application runtime. Use the Scripting Console or KeyShot’s documented command-line script launcher. Starting python batch_render.py in a standard terminal does not establish access to the open KeyShot scene.

Is GPU Mode Required for the Batch?

The manifest and job loop do not require GPU mode. KeyShot supports CPU rendering and offers GPU mode on compatible hardware. Check your release’s rendering modes, then test a representative image in the mode you plan to use. Batch automation does not guarantee a particular speedup.

Is Rendering on Vagon the Same as KeyShot Cloud Rendering?

Here, KeyShot runs on your Vagon Windows computer and writes images to that computer’s storage. KeyShot Cloud Rendering and Network Rendering are separate rendering routes. This script does not submit jobs to either service, and neither is a prerequisite for the local batch described above.

Vagon Computer

Run it on a cloud computer

Open it from any browser, Mac, iPad or Chromebook. No local hardware needed.

Vagon Computer

Run it on a cloud computer

Open it from any browser, Mac, iPad or Chromebook. No local hardware needed.

Written by

Mert Boz

Mert is a philosophy student with a strong interest in economics, motorsports, and basketball. He enjoys thinking about how the world works, both in theory and in real life. In his free time, you can often find him watching a race, following a basketball game, or reading about economic ideas.

More posts by this writer →

Vagon Computer

Run this guide on a cloud computer.

Open it from any browser, Mac, iPad or Chromebook. No local hardware needed.

Vagon Computer

Run this guide on a cloud computer.

Open it from any browser, Mac, iPad or Chromebook. No local hardware needed.