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.
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.
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-batchcodex
cd C:\Projects\bottle-batchcodex
cd C:\Projects\bottle-batchcodex
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.
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:
Enable Multi-Materials and select the corresponding sub-material for bottle_body.
Assign the same approved environment and image style.
Leave Camera unchecked so the job list can choose the view independently.
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.
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.
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:
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:
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.
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.
importjsonfrompathlibimportPathimportluxroot = Path(r"C:\Projects\bottle-batch")config = json.loads((root / "config/jobs.json").read_text(encoding="utf-8"))job = next(jforjinconfig["jobs"]ifj["id"] == "matte_black_front")scene = root / config["scene"]output = root / "renders/preview" / f"BTL-01_{job['id']}_v01.png"ifnotscene.is_file():
raiseFileNotFoundError(scene)ifoutput.exists():
raiseFileExistsError(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)forvalueinscene_info.values()ifisinstance(value,str)andPath(value).is_absolute()andPath(value).suffix.lower() == ".bip"]ifnotloaded_pathsorany(notpath.samefile(scene)forpathinloaded_paths):
raiseRuntimeError(f"Cannot verify the loaded .bip path against {scene}: {scene_info}")ifjob["studio"]notinlist(lux.getStudios()):
raiseValueError("Required Studio is missing")ifjob["camera"]notinlist(lux.getCameras()):
raiseValueError("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)ifnotoutput.is_file()oroutput.stat().st_size == 0:
raiseRuntimeError("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}")
importjsonfrompathlibimportPathimportluxroot = Path(r"C:\Projects\bottle-batch")config = json.loads((root / "config/jobs.json").read_text(encoding="utf-8"))job = next(jforjinconfig["jobs"]ifj["id"] == "matte_black_front")scene = root / config["scene"]output = root / "renders/preview" / f"BTL-01_{job['id']}_v01.png"ifnotscene.is_file():
raiseFileNotFoundError(scene)ifoutput.exists():
raiseFileExistsError(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)forvalueinscene_info.values()ifisinstance(value,str)andPath(value).is_absolute()andPath(value).suffix.lower() == ".bip"]ifnotloaded_pathsorany(notpath.samefile(scene)forpathinloaded_paths):
raiseRuntimeError(f"Cannot verify the loaded .bip path against {scene}: {scene_info}")ifjob["studio"]notinlist(lux.getStudios()):
raiseValueError("Required Studio is missing")ifjob["camera"]notinlist(lux.getCameras()):
raiseValueError("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)ifnotoutput.is_file()oroutput.stat().st_size == 0:
raiseRuntimeError("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}")
importjsonfrompathlibimportPathimportluxroot = Path(r"C:\Projects\bottle-batch")config = json.loads((root / "config/jobs.json").read_text(encoding="utf-8"))job = next(jforjinconfig["jobs"]ifj["id"] == "matte_black_front")scene = root / config["scene"]output = root / "renders/preview" / f"BTL-01_{job['id']}_v01.png"ifnotscene.is_file():
raiseFileNotFoundError(scene)ifoutput.exists():
raiseFileExistsError(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)forvalueinscene_info.values()ifisinstance(value,str)andPath(value).is_absolute()andPath(value).suffix.lower() == ".bip"]ifnotloaded_pathsorany(notpath.samefile(scene)forpathinloaded_paths):
raiseRuntimeError(f"Cannot verify the loaded .bip path against {scene}: {scene_info}")ifjob["studio"]notinlist(lux.getStudios()):
raiseValueError("Required Studio is missing")ifjob["camera"]notinlist(lux.getCameras()):
raiseValueError("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)ifnotoutput.is_file()oroutput.stat().st_size == 0:
raiseRuntimeError("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.
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.
importhashlibimportjsonfrompathlibimportPathimportuuidimportluxroot = 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"))defproject_path(value):
path = (root / value).resolve()ifPath(value).is_absolute()ornotpath.is_relative_to(root):
raiseValueError(f"Expected a project-relative path: {value}")returnpathscene = project_path(config["scene"])output_root = project_path(config["output_dir"])ifnotscene.is_file():
raiseFileNotFoundError(scene)forkeyin("width","height","samples"):
iftype(config[key])isnotintorconfig[key] < 1:
raiseValueError(f"Invalid {key}")iftype(config["alpha"])isnotbool:
raiseValueError("alpha must be true or false")finishes = {"matte_black","brushed_steel","warm_white"}cameras = {"front","three_quarter","detail"}expected = {f"{s}_{c}": (s,c)forsinfinishesforcincameras}jobs = config["jobs"]ids = [j["id"]forjinjobs]iflen(ids) != len(set(ids))orset(ids) != set(expected):
raiseValueError("Expected nine unique finish-camera jobs")forjobinjobs:
if(job["studio"],job["camera"]) != expected[job["id"]]:
raiseValueError(f"Incorrect mapping: {job['id']}")ifSELECT_IDS - set(ids):
raiseValueError("Unknown retry IDs")selected = [jforjinjobsifnotSELECT_IDSorj["id"]inSELECT_IDS]lux.newScene(dontAsk=False)lux.openFile(str(scene),dontAsk=False)scene_info = lux.getSceneInfo()loaded_paths = [Path(value)forvalueinscene_info.values()ifisinstance(value,str)andPath(value).is_absolute()andPath(value).suffix.lower() == ".bip"]ifnotloaded_pathsorany(notpath.samefile(scene)forpathinloaded_paths):
raiseRuntimeError(f"Cannot verify the loaded .bip path against {scene}: {scene_info}")ifnotlux.hasContents():
raiseRuntimeError("The saved scene did not load content")ifnotfinishes <= set(lux.getStudios())ornotcameras <= set(lux.getCameras()):
raiseValueError("Required Studios or cameras are missing")styles = set()forfinishinfinishes:
studio = lux.getStudio(finish)if(notstudio.isMultiMaterialsEnabled()ordict(studio.getMultiMaterials()).get("bottle_body") != finish):
raiseValueError(f"Incorrect material selection: {finish}")styles.add((studio.getEnvironment(),studio.getImageStyle()))iflen(styles) != 1orany(visNoneforvinnext(iter(styles))):
raiseValueError("Assign the same environment and image style to all Studios")run_id = uuid.uuid4().hexrun_dir = output_root / run_idrun_dir.mkdir(parents=True,exist_ok=False)log_path = root / "logs" / f"batch_{run_id}.json"log_path.parent.mkdir(exist_ok=True)withscene.open("rb")ashandle:
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"]forjinselected],"results": []}defsave_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()forjobinselected:
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)ifnotoutput.is_file()oroutput.stat().st_size == 0:
raiseRuntimeError("Missing or empty output")result["status"] = "written"exceptExceptionaserror:
result.update(status="failed",error=str(error))raisefinally:
save_report()print(f"Batch written. Inspect images and report: {log_path}")
importhashlibimportjsonfrompathlibimportPathimportuuidimportluxroot = 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"))defproject_path(value):
path = (root / value).resolve()ifPath(value).is_absolute()ornotpath.is_relative_to(root):
raiseValueError(f"Expected a project-relative path: {value}")returnpathscene = project_path(config["scene"])output_root = project_path(config["output_dir"])ifnotscene.is_file():
raiseFileNotFoundError(scene)forkeyin("width","height","samples"):
iftype(config[key])isnotintorconfig[key] < 1:
raiseValueError(f"Invalid {key}")iftype(config["alpha"])isnotbool:
raiseValueError("alpha must be true or false")finishes = {"matte_black","brushed_steel","warm_white"}cameras = {"front","three_quarter","detail"}expected = {f"{s}_{c}": (s,c)forsinfinishesforcincameras}jobs = config["jobs"]ids = [j["id"]forjinjobs]iflen(ids) != len(set(ids))orset(ids) != set(expected):
raiseValueError("Expected nine unique finish-camera jobs")forjobinjobs:
if(job["studio"],job["camera"]) != expected[job["id"]]:
raiseValueError(f"Incorrect mapping: {job['id']}")ifSELECT_IDS - set(ids):
raiseValueError("Unknown retry IDs")selected = [jforjinjobsifnotSELECT_IDSorj["id"]inSELECT_IDS]lux.newScene(dontAsk=False)lux.openFile(str(scene),dontAsk=False)scene_info = lux.getSceneInfo()loaded_paths = [Path(value)forvalueinscene_info.values()ifisinstance(value,str)andPath(value).is_absolute()andPath(value).suffix.lower() == ".bip"]ifnotloaded_pathsorany(notpath.samefile(scene)forpathinloaded_paths):
raiseRuntimeError(f"Cannot verify the loaded .bip path against {scene}: {scene_info}")ifnotlux.hasContents():
raiseRuntimeError("The saved scene did not load content")ifnotfinishes <= set(lux.getStudios())ornotcameras <= set(lux.getCameras()):
raiseValueError("Required Studios or cameras are missing")styles = set()forfinishinfinishes:
studio = lux.getStudio(finish)if(notstudio.isMultiMaterialsEnabled()ordict(studio.getMultiMaterials()).get("bottle_body") != finish):
raiseValueError(f"Incorrect material selection: {finish}")styles.add((studio.getEnvironment(),studio.getImageStyle()))iflen(styles) != 1orany(visNoneforvinnext(iter(styles))):
raiseValueError("Assign the same environment and image style to all Studios")run_id = uuid.uuid4().hexrun_dir = output_root / run_idrun_dir.mkdir(parents=True,exist_ok=False)log_path = root / "logs" / f"batch_{run_id}.json"log_path.parent.mkdir(exist_ok=True)withscene.open("rb")ashandle:
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"]forjinselected],"results": []}defsave_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()forjobinselected:
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)ifnotoutput.is_file()oroutput.stat().st_size == 0:
raiseRuntimeError("Missing or empty output")result["status"] = "written"exceptExceptionaserror:
result.update(status="failed",error=str(error))raisefinally:
save_report()print(f"Batch written. Inspect images and report: {log_path}")
importhashlibimportjsonfrompathlibimportPathimportuuidimportluxroot = 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"))defproject_path(value):
path = (root / value).resolve()ifPath(value).is_absolute()ornotpath.is_relative_to(root):
raiseValueError(f"Expected a project-relative path: {value}")returnpathscene = project_path(config["scene"])output_root = project_path(config["output_dir"])ifnotscene.is_file():
raiseFileNotFoundError(scene)forkeyin("width","height","samples"):
iftype(config[key])isnotintorconfig[key] < 1:
raiseValueError(f"Invalid {key}")iftype(config["alpha"])isnotbool:
raiseValueError("alpha must be true or false")finishes = {"matte_black","brushed_steel","warm_white"}cameras = {"front","three_quarter","detail"}expected = {f"{s}_{c}": (s,c)forsinfinishesforcincameras}jobs = config["jobs"]ids = [j["id"]forjinjobs]iflen(ids) != len(set(ids))orset(ids) != set(expected):
raiseValueError("Expected nine unique finish-camera jobs")forjobinjobs:
if(job["studio"],job["camera"]) != expected[job["id"]]:
raiseValueError(f"Incorrect mapping: {job['id']}")ifSELECT_IDS - set(ids):
raiseValueError("Unknown retry IDs")selected = [jforjinjobsifnotSELECT_IDSorj["id"]inSELECT_IDS]lux.newScene(dontAsk=False)lux.openFile(str(scene),dontAsk=False)scene_info = lux.getSceneInfo()loaded_paths = [Path(value)forvalueinscene_info.values()ifisinstance(value,str)andPath(value).is_absolute()andPath(value).suffix.lower() == ".bip"]ifnotloaded_pathsorany(notpath.samefile(scene)forpathinloaded_paths):
raiseRuntimeError(f"Cannot verify the loaded .bip path against {scene}: {scene_info}")ifnotlux.hasContents():
raiseRuntimeError("The saved scene did not load content")ifnotfinishes <= set(lux.getStudios())ornotcameras <= set(lux.getCameras()):
raiseValueError("Required Studios or cameras are missing")styles = set()forfinishinfinishes:
studio = lux.getStudio(finish)if(notstudio.isMultiMaterialsEnabled()ordict(studio.getMultiMaterials()).get("bottle_body") != finish):
raiseValueError(f"Incorrect material selection: {finish}")styles.add((studio.getEnvironment(),studio.getImageStyle()))iflen(styles) != 1orany(visNoneforvinnext(iter(styles))):
raiseValueError("Assign the same environment and image style to all Studios")run_id = uuid.uuid4().hexrun_dir = output_root / run_idrun_dir.mkdir(parents=True,exist_ok=False)log_path = root / "logs" / f"batch_{run_id}.json"log_path.parent.mkdir(exist_ok=True)withscene.open("rb")ashandle:
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"]forjinselected],"results": []}defsave_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()forjobinselected:
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)ifnotoutput.is_file()oroutput.stat().st_size == 0:
raiseRuntimeError("Missing or empty output")result["status"] = "written"exceptExceptionaserror:
result.update(status="failed",error=str(error))raisefinally:
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.
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.