fix(blender): align docs and render execute with real behavior

This commit is contained in:
yuhao
2026-04-21 06:38:35 +00:00
parent 8329750966
commit d676e019ca
7 changed files with 328 additions and 136 deletions
+5 -4
View File
@@ -1168,6 +1168,7 @@ $ cli-anything-libreoffice --project report.json writer add-table --rows 4 --col
✓ Added 4×3 table
# Export to real PDF via LibreOffice headless
# Requires a local LibreOffice/soffice installation in PATH.
$ cli-anything-libreoffice --project report.json export render output.pdf -p pdf --overwrite
✓ Exported: output.pdf (42,831 bytes) via libreoffice-headless
@@ -1194,11 +1195,11 @@ $ cli-anything-blender
blender> scene new --name ProductShot
✓ Created scene: ProductShot
blender[ProductShot]> object add-mesh --type cube --location 0 0 1
✓ Added mesh: Cube at (0, 0, 1)
blender[ProductShot]> object add cube --location 0,0,1
✓ Added cube: Cube
blender[ProductShot]*> render execute --output render.png --engine CYCLES
✓ Rendered: render.png (1920×1080, 2.3 MB) via blender --background
blender[ProductShot]*> render execute render.png --engine CYCLES --overwrite
✓ Rendered: /absolute/path/render0001.png (84,512 bytes) via blender-headless
blender[ProductShot]> exit
Goodbye! 👋
+5 -4
View File
@@ -749,6 +749,7 @@ $ cli-anything-libreoffice --project report.json writer add-table --rows 4 --col
✓ Added 4×3 table
# 通过 LibreOffice headless 导出为真实 PDF
# 需要本机 PATH 中存在 LibreOffice/soffice 可执行文件。
$ cli-anything-libreoffice --project report.json export render output.pdf -p pdf --overwrite
✓ Exported: output.pdf (42,831 bytes) via libreoffice-headless
@@ -775,11 +776,11 @@ $ cli-anything-blender
blender> scene new --name ProductShot
✓ Created scene: ProductShot
blender[ProductShot]> object add-mesh --type cube --location 0 0 1
✓ Added mesh: Cube at (0, 0, 1)
blender[ProductShot]> object add cube --location 0,0,1
✓ Added cube: Cube
blender[ProductShot]*> render execute --output render.png --engine CYCLES
✓ Rendered: render.png (1920×1080, 2.3 MB) via blender --background
blender[ProductShot]*> render execute render.png --engine CYCLES --overwrite
✓ Rendered: /absolute/path/render0001.png (84,512 bytes) via blender-headless
blender[ProductShot]> exit
Goodbye! 👋
@@ -2,47 +2,48 @@
A stateful command-line interface for 3D scene editing, following the same
patterns as the GIMP CLI harness. Uses a JSON scene description format
with bpy script generation for actual Blender rendering.
and can either render directly through Blender headless or emit the bpy script.
## Installation
```bash
# From the agent-harness directory:
pip install click prompt_toolkit
pip install -e .
# No Blender installation required for scene editing.
# Blender is only needed if you want to execute the generated render scripts.
# Scene editing works without Blender.
# `render execute` requires a real Blender binary in PATH.
# `render script` works without Blender.
```
## Quick Start
```bash
# Create a new scene
python3 -m cli.blender_cli scene new --name "MyScene" -o scene.json
cli-anything-blender scene new --name "MyScene" -o scene.json
# Add objects
python3 -m cli.blender_cli --project scene.json object add cube --name "Box"
python3 -m cli.blender_cli --project scene.json object add sphere --name "Ball" -l 3,0,1
cli-anything-blender --project scene.json object add cube --name "Box"
cli-anything-blender --project scene.json object add sphere --name "Ball" -l 3,0,1
# Create and assign materials
python3 -m cli.blender_cli --project scene.json material create --name "Red" --color 1,0,0,1
python3 -m cli.blender_cli --project scene.json material assign 0 0
cli-anything-blender --project scene.json material create --name "Red" --color 1,0,0,1
cli-anything-blender --project scene.json material assign 0 0
# Add modifiers
python3 -m cli.blender_cli --project scene.json modifier add subdivision_surface -o 0 -p levels=2
cli-anything-blender --project scene.json modifier add subdivision_surface -o 0 -p levels=2
# Add camera and light
python3 -m cli.blender_cli --project scene.json camera add -l 7,-6,5 -r 63,0,46 --active
python3 -m cli.blender_cli --project scene.json light add sun -r -45,0,30
cli-anything-blender --project scene.json camera add -l 7,-6,5 -r 63,0,46 --active
cli-anything-blender --project scene.json light add sun -r -45,0,30
# Save
python3 -m cli.blender_cli --project scene.json scene save
cli-anything-blender --project scene.json scene save
# Generate render script
python3 -m cli.blender_cli --project scene.json render execute render.png --overwrite
# Render directly with Blender headless
cli-anything-blender --project scene.json render execute render.png --engine CYCLES --overwrite
# Execute with Blender (if installed)
blender --background --python /path/to/_render_script.py
# Or just emit the bpy script
cli-anything-blender --project scene.json render script render.png > render.py
```
## JSON Output Mode
@@ -50,16 +51,16 @@ blender --background --python /path/to/_render_script.py
All commands support `--json` for machine-readable output:
```bash
python3 -m cli.blender_cli --json scene new -o scene.json
python3 -m cli.blender_cli --json --project scene.json object list
cli-anything-blender --json scene new -o scene.json
cli-anything-blender --json --project scene.json object list
```
## Interactive REPL
```bash
python3 -m cli.blender_cli repl
cli-anything-blender
# or with existing project:
python3 -m cli.blender_cli repl --project scene.json
cli-anything-blender repl --project scene.json
```
## Command Groups
@@ -133,8 +134,8 @@ animation list-keyframes - List keyframes for an object
render settings - Configure render settings
render info - Show current render settings
render presets - List available render presets
render execute - Render the scene (generates bpy script)
render script - Generate bpy script to stdout
render execute - Render the scene via Blender headless (requires Blender)
render script - Generate bpy script to stdout without rendering
```
### Session
@@ -151,42 +152,40 @@ session history - Show undo history
# From the agent-harness directory:
# Run all tests
python3 -m pytest cli/tests/ -v
python3 -m pytest cli_anything/blender/tests/ -v
# Run unit tests only
python3 -m pytest cli/tests/test_core.py -v
python3 -m pytest cli_anything/blender/tests/test_core.py -v
# Run E2E tests only
python3 -m pytest cli/tests/test_full_e2e.py -v
python3 -m pytest cli_anything/blender/tests/test_full_e2e.py -v
# Run with coverage
python3 -m pytest cli/tests/ -v --tb=short
python3 -m pytest cli_anything/blender/tests/ -v --tb=short
```
## Architecture
```
cli/
```text
cli_anything/blender/
├── __init__.py
├── __main__.py # python3 -m cli.blender_cli
├── blender_cli.py # Main CLI entry point (Click + REPL)
├── __main__.py # python3 -m cli_anything.blender.blender_cli
├── blender_cli.py # Main CLI entry point (Click + REPL)
├── core/
│ ├── __init__.py
│ ├── scene.py # Scene create/open/save/info
│ ├── objects.py # 3D object management
│ ├── materials.py # Material management
│ ├── modifiers.py # Modifier registry + add/remove/set
│ ├── lighting.py # Camera and light management
│ ├── animation.py # Keyframe and timeline management
── render.py # Render settings and export
│ └── session.py # Stateful session, undo/redo
│ ├── scene.py # Scene create/open/save/info
│ ├── objects.py # 3D object management
│ ├── materials.py # Material management
│ ├── modifiers.py # Modifier registry + add/remove/set
│ ├── lighting.py # Camera and light management
│ ├── animation.py # Keyframe and timeline management
│ ├── render.py # Render settings and execution
── session.py # Stateful session, undo/redo
├── utils/
│ ├── __init__.py
│ └── bpy_gen.py # Blender Python script generation
│ ├── blender_backend.py # Blender executable wrapper
│ └── bpy_gen.py # Blender Python script generation
└── tests/
├── __init__.py
── test_core.py # Unit tests (synthetic data, 100+ tests)
└── test_full_e2e.py # E2E tests (script gen, roundtrips, workflows)
├── test_core.py # Unit tests
── test_full_e2e.py # E2E tests
```
## JSON Scene Format
@@ -212,10 +211,10 @@ The scene is stored as a JSON file with this structure:
## Rendering
Since Blender's `.blend` format is binary, this CLI uses a JSON scene format
and generates Blender Python (bpy) scripts for rendering. The workflow:
and generates Blender Python (bpy) scripts as its interchange format. The workflow:
1. Edit the scene using CLI commands (creates/modifies JSON)
2. Generate a bpy script with `render execute` or `render script`
3. Run the script with `blender --background --python script.py`
2. Run `render execute` to render directly with Blender headless, or `render script` to emit the script
3. If you used `render script`, run it with `blender --background --python script.py`
The generated scripts reconstruct the entire scene in Blender and render it.
@@ -2,18 +2,19 @@
"""Blender CLI — A stateful command-line interface for 3D scene editing.
This CLI provides full 3D scene management capabilities using a JSON
scene description format, with bpy script generation for actual rendering.
scene description format, with direct Blender-backed rendering support.
Usage:
# One-shot commands
python3 -m cli.blender_cli scene new --name "MyScene"
python3 -m cli.blender_cli object add cube --name "MyCube"
python3 -m cli.blender_cli material create --name "Red" --color 1,0,0,1
cli-anything-blender scene new --name "MyScene"
cli-anything-blender object add cube --name "MyCube"
cli-anything-blender material create --name "Red" --color 1,0,0,1
# Interactive REPL
python3 -m cli.blender_cli repl
cli-anything-blender
"""
import copy
import sys
import os
import json
@@ -37,6 +38,7 @@ from cli_anything.blender.core import render as render_mod
_session: Optional[Session] = None
_json_output = False
_repl_mode = False
OBJECT_MESH_TYPES = ["cube", "sphere", "cylinder", "cone", "plane", "torus", "monkey", "empty"]
def get_session() -> Session:
@@ -83,6 +85,49 @@ def _print_list(items: list, indent: int = 0):
click.echo(f"{prefix}- {item}")
def _parse_vector_value(value, option_name: str):
if value is None:
return None
if isinstance(value, str):
parts = [part.strip() for part in value.split(",") if part.strip()]
else:
parts = [str(part).strip() for part in value]
if len(parts) != 3:
raise ValueError(f"{option_name} must have exactly 3 components.")
return [float(part) for part in parts]
def _parse_params(param_values):
params = {}
for param_value in param_values:
if "=" not in param_value:
raise ValueError(f"Invalid param format: '{param_value}'. Use key=value.")
key, value = param_value.split("=", 1)
try:
value = float(value) if "." in value else int(value)
except ValueError:
pass
params[key] = value
return params
def _add_object_impl(mesh_type, name, location, rotation, scale, param, collection):
loc = _parse_vector_value(location, "Location")
rot = _parse_vector_value(rotation, "Rotation")
scl = _parse_vector_value(scale, "Scale")
params = _parse_params(param)
sess = get_session()
sess.snapshot(f"Add object: {mesh_type}")
proj = sess.get_project()
obj = obj_mod.add_object(
proj, mesh_type=mesh_type, name=name, location=loc,
rotation=rot, scale=scl, mesh_params=params if params else None,
collection=collection,
)
output(obj, f"Added {mesh_type}: {obj['name']}")
def handle_error(func):
def wrapper(*args, **kwargs):
try:
@@ -240,8 +285,7 @@ def object_group():
@object_group.command("add")
@click.argument("mesh_type", type=click.Choice(
["cube", "sphere", "cylinder", "cone", "plane", "torus", "monkey", "empty"]))
@click.argument("mesh_type", type=click.Choice(OBJECT_MESH_TYPES))
@click.option("--name", "-n", default=None, help="Object name")
@click.option("--location", "-l", default=None, help="Location x,y,z")
@click.option("--rotation", "-r", default=None, help="Rotation x,y,z (degrees)")
@@ -251,30 +295,22 @@ def object_group():
@handle_error
def object_add(mesh_type, name, location, rotation, scale, param, collection):
"""Add a 3D primitive object."""
loc = [float(x) for x in location.split(",")] if location else None
rot = [float(x) for x in rotation.split(",")] if rotation else None
scl = [float(x) for x in scale.split(",")] if scale else None
_add_object_impl(mesh_type, name, location, rotation, scale, param, collection)
params = {}
for p in param:
if "=" not in p:
raise ValueError(f"Invalid param format: '{p}'. Use key=value.")
k, v = p.split("=", 1)
try:
v = float(v) if "." in v else int(v)
except ValueError:
pass
params[k] = v
sess = get_session()
sess.snapshot(f"Add object: {mesh_type}")
proj = sess.get_project()
obj = obj_mod.add_object(
proj, mesh_type=mesh_type, name=name, location=loc,
rotation=rot, scale=scl, mesh_params=params if params else None,
collection=collection,
)
output(obj, f"Added {mesh_type}: {obj['name']}")
@object_group.command("add-mesh")
@click.option("--type", "mesh_type", type=click.Choice(OBJECT_MESH_TYPES), required=True,
help="Primitive mesh type")
@click.option("--name", "-n", default=None, help="Object name")
@click.option("--location", "-l", nargs=3, type=float, default=None, help="Location x y z")
@click.option("--rotation", "-r", nargs=3, type=float, default=None, help="Rotation x y z (degrees)")
@click.option("--scale", "-s", nargs=3, type=float, default=None, help="Scale x y z")
@click.option("--param", "-p", multiple=True, help="Mesh parameter: key=value")
@click.option("--collection", "-c", default=None, help="Target collection")
@handle_error
def object_add_mesh(mesh_type, name, location, rotation, scale, param, collection):
"""Compatibility alias for `object add`."""
_add_object_impl(mesh_type, name, location, rotation, scale, param, collection)
@object_group.command("remove")
@@ -784,19 +820,55 @@ def render_presets():
@render_group.command("execute")
@click.argument("output_path")
@click.argument("output_path", required=False)
@click.option("--output", "output_option", type=str, default=None,
help="Render output path (alternative to positional OUTPUT_PATH)")
@click.option("--engine", type=click.Choice(["CYCLES", "EEVEE", "WORKBENCH"]), default=None,
help="Override render engine for this render")
@click.option("--samples", type=int, default=None, help="Override render samples for this render")
@click.option("--format", "output_format", default=None, help="Override output format for this render")
@click.option("--preset", default=None, help="Apply a render preset for this render")
@click.option("--frame", "-f", type=int, default=None, help="Specific frame to render")
@click.option("--animation", "-a", is_flag=True, help="Render full animation")
@click.option("--overwrite", is_flag=True, help="Overwrite existing file")
@handle_error
def render_execute(output_path, frame, animation, overwrite):
"""Render the scene (generates bpy script)."""
def render_execute(output_path, output_option, engine, samples, output_format, preset,
frame, animation, overwrite):
"""Render the scene via Blender headless."""
if output_path and output_option:
raise ValueError("Specify the output path either positionally or with --output, not both.")
target_output = output_option or output_path
if not target_output:
raise ValueError("Output path is required. Pass OUTPUT_PATH or --output.")
sess = get_session()
project = sess.get_project()
if any(value is not None for value in (engine, samples, output_format, preset)):
project = copy.deepcopy(project)
render_mod.set_render_settings(
project,
engine=engine,
samples=samples,
output_format=output_format,
preset=preset,
)
result = render_mod.render_scene(
sess.get_project(), output_path,
frame=frame, animation=animation, overwrite=overwrite,
project,
target_output,
frame=frame,
animation=animation,
overwrite=overwrite,
execute=True,
)
output(result, f"Render script generated: {result['script_path']}")
if animation:
message = (
f"Rendered animation: {result['output_count']} output(s) via {result['method']}"
)
else:
message = f"Rendered: {result['output']} ({result['file_size']:,} bytes) via {result['method']}"
output(result, message)
@render_group.command("script")
@@ -5,9 +5,9 @@ for actual Blender rendering.
"""
import os
import json
from typing import Dict, Any, Optional, List
from datetime import datetime
from cli_anything.blender.utils import blender_backend
# Render presets
@@ -185,11 +185,10 @@ def render_scene(
frame: Optional[int] = None,
animation: bool = False,
overwrite: bool = False,
execute: bool = False,
timeout: int = 300,
) -> Dict[str, Any]:
"""Render the scene by generating a bpy script.
Since we cannot call Blender directly in all environments, this generates
a Python script that can be run with `blender --background --python script.py`.
"""Prepare a render script, and optionally execute it with Blender.
Args:
project: The scene dict
@@ -197,9 +196,11 @@ def render_scene(
frame: Specific frame to render (None = current frame)
animation: If True, render the full animation range
overwrite: Allow overwriting existing files
execute: If True, run Blender headless after generating the script
timeout: Maximum seconds to wait when execute=True
Returns:
Dict with render info and script path
Dict with render info, script path, and optional backend output metadata
"""
if os.path.exists(output_path) and not overwrite and not animation:
raise FileExistsError(f"Output file exists: {output_path}. Use --overwrite.")
@@ -236,6 +237,32 @@ def render_scene(
else:
result["frame"] = frame or scene_settings.get("frame_current", 1)
if execute:
backend_result = blender_backend.render_script(
result["script_path"],
output_path=result["output_path"],
animation=animation,
timeout=timeout,
)
if backend_result["returncode"] != 0:
raise RuntimeError(
f"Blender render failed (exit {backend_result['returncode']}):\n"
f" stderr: {backend_result['stderr'][-500:]}"
)
result.update({
"executed": True,
"output": backend_result["output"],
"outputs": backend_result["outputs"],
"output_count": backend_result["output_count"],
"file_size": backend_result["file_size"],
"blender_version": backend_result["blender_version"],
"method": backend_result["method"],
"returncode": backend_result["returncode"],
})
else:
result["executed"] = False
return result
@@ -288,7 +288,7 @@ class TestBPYScriptGeneration:
def test_script_render_settings_eevee(self):
proj = create_scene(engine="EEVEE", samples=64)
script = generate_full_script(proj, "/tmp/render.png")
assert "BLENDER_EEVEE_NEXT" in script
assert "BLENDER_EEVEE" in script
assert "eevee.taa_render_samples" in script
def test_script_world_settings(self):
@@ -611,25 +611,39 @@ class TestCLISubprocess:
assert result.returncode == 0
assert "cycles_default" in result.stdout
def test_legacy_add_mesh_command(self, tmp_dir):
proj_path = os.path.join(tmp_dir, "legacy.json")
self._run(["scene", "new", "-o", proj_path, "-n", "legacy"])
result = self._run([
"--project", proj_path,
"object", "add-mesh",
"--type", "cube",
"--location", "0", "0", "1",
])
assert result.returncode == 0
with open(proj_path) as f:
data = json.load(f)
assert len(data["objects"]) == 1
assert data["objects"][0]["mesh_type"] == "cube"
assert data["objects"][0]["location"] == [0.0, 0.0, 1.0]
def test_full_workflow_json(self, tmp_dir):
proj_path = os.path.join(tmp_dir, "workflow.json")
# Create scene
self._run(["--json", "scene", "new", "-o", proj_path, "-n", "workflow"])
# Add object and save (each subprocess is a separate session)
self._run(["--json", "--project", proj_path,
"object", "add", "cube", "--name", "Box"])
# Add object and verify the one-shot command auto-saved the project.
self._run(["--json", "--project", proj_path, "object", "add", "cube", "--name", "Box"])
# Since each subprocess is a separate session, the object add above
# loads the project, adds the object, but doesn't auto-save.
# We need to verify the CLI works correctly in a single invocation.
# Instead, verify the project file was created correctly and test
# direct API roundtrip.
assert os.path.exists(proj_path)
with open(proj_path) as f:
data = json.load(f)
assert data["name"] == "workflow"
assert len(data["objects"]) == 1
assert data["objects"][0]["name"] == "Box"
# Test that the scene file is valid
loaded_result = self._run(["--json", "--project", proj_path, "scene", "info"])
@@ -730,6 +744,48 @@ class TestBlenderBackend:
class TestBlenderRenderE2E:
"""True E2E tests: generate scene → bpy script → blender --background → verify output."""
def test_cli_render_execute(self, tmp_dir):
"""Render through the public CLI command, not just the backend helper."""
from cli_anything.blender.utils.blender_backend import find_render_outputs
proj = create_scene(name="cli_render", engine="WORKBENCH", samples=1)
set_render_settings(proj, resolution_x=320, resolution_y=240, engine="WORKBENCH", samples=1)
add_object(proj, mesh_type="cube", name="TestCube", location=[0, 0, 0])
add_camera(proj, name="Cam", location=[5, -5, 3],
rotation=[63, 0, 46], set_active=True)
add_light(proj, light_type="SUN", name="Sun", rotation=[-45, 0, 30])
proj_path = os.path.join(tmp_dir, "cli_render.json")
save_scene(proj, proj_path)
output_path = os.path.join(tmp_dir, "cli_render.png")
result = subprocess.run(
_resolve_cli("cli-anything-blender") + [
"--json",
"--project", proj_path,
"render", "execute",
"--output", output_path,
"--engine", "WORKBENCH",
"--samples", "1",
"--overwrite",
],
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, result.stderr
payload = json.loads(result.stdout)
assert payload["executed"] is True
assert payload["method"] == "blender-headless"
outputs = find_render_outputs(output_path)
assert outputs
assert os.path.exists(payload["output"])
assert payload["output"] == os.path.abspath(outputs[0])
assert payload["file_size"] > 0
print(f"\n CLI render: {payload['output']} ({payload['file_size']:,} bytes)")
def test_render_simple_cube(self, tmp_dir):
"""Render a simple cube scene with Blender."""
from cli_anything.blender.utils.blender_backend import render_scene_headless
@@ -4,6 +4,7 @@ Requires: blender (system package)
apt install blender
"""
import glob
import os
import shutil
import subprocess
@@ -11,6 +12,9 @@ import tempfile
from typing import Optional
_FRAME_SUFFIXES = ("0001", "0000", "1")
def find_blender() -> str:
"""Find the Blender executable. Raises RuntimeError if not found."""
for name in ("blender",):
@@ -34,18 +38,49 @@ def get_version() -> str:
return result.stdout.strip().split("\n")[0]
def find_render_outputs(output_path: str, animation: bool = False) -> list[str]:
"""Resolve Blender's actual output file(s) for a requested render path."""
abs_output_path = os.path.abspath(output_path)
base, ext = os.path.splitext(abs_output_path)
direct_candidates = [abs_output_path]
if ext:
direct_candidates.extend(f"{base}{suffix}{ext}" for suffix in _FRAME_SUFFIXES)
else:
direct_candidates.extend(f"{abs_output_path}{suffix}" for suffix in _FRAME_SUFFIXES)
for candidate in direct_candidates:
if os.path.exists(candidate):
return [candidate]
patterns = [f"{base}*{ext}"] if ext else [f"{abs_output_path}*"]
matches = sorted(
path
for pattern in patterns
for path in glob.glob(pattern)
if os.path.isfile(path)
)
if animation:
return matches
return matches[:1]
def render_script(
script_path: str,
output_path: Optional[str] = None,
animation: bool = False,
timeout: int = 300,
) -> dict:
"""Run a bpy script using Blender headless.
Args:
script_path: Path to the Python script to execute
output_path: Expected render output path
animation: Whether Blender is expected to render an animation sequence
timeout: Maximum seconds to wait
Returns:
Dict with stdout, stderr, return code
Dict with stdout, stderr, return code, and optional output metadata
"""
if not os.path.exists(script_path):
raise FileNotFoundError(f"Script not found: {script_path}")
@@ -59,13 +94,35 @@ def render_script(
timeout=timeout,
)
return {
render_result = {
"command": " ".join(cmd),
"returncode": result.returncode,
"stdout": result.stdout,
"stderr": result.stderr,
}
if output_path and result.returncode == 0:
outputs = find_render_outputs(output_path, animation=animation)
if not outputs:
raise RuntimeError(
"Blender render produced no output file.\n"
f" Expected: {output_path}\n"
f" stdout: {result.stdout[-500:]}"
)
primary_output = outputs[0]
render_result.update({
"output": os.path.abspath(primary_output),
"outputs": [os.path.abspath(path) for path in outputs],
"output_count": len(outputs),
"format": os.path.splitext(primary_output)[1].lstrip("."),
"method": "blender-headless",
"blender_version": get_version(),
"file_size": os.path.getsize(primary_output),
})
return render_result
def render_scene_headless(
bpy_script_content: str,
@@ -89,40 +146,19 @@ def render_scene_headless(
script_path = f.name
try:
result = render_script(script_path, timeout=timeout)
result = render_script(script_path, output_path=output_path, timeout=timeout)
if result["returncode"] != 0:
raise RuntimeError(
f"Blender render failed (exit {result['returncode']}):\n"
f" stderr: {result['stderr'][-500:]}"
)
# Verify the output file was created
# Blender appends frame number to output path for single frames
# e.g., /tmp/render.png becomes /tmp/render0001.png
actual_output = output_path
if not os.path.exists(actual_output):
# Try with frame number suffix
base, ext = os.path.splitext(output_path)
for suffix in ["0001", "0000", "1"]:
candidate = f"{base}{suffix}{ext}"
if os.path.exists(candidate):
actual_output = candidate
break
if not os.path.exists(actual_output):
raise RuntimeError(
f"Blender render produced no output file.\n"
f" Expected: {output_path}\n"
f" stdout: {result['stdout'][-500:]}"
)
return {
"output": os.path.abspath(actual_output),
"format": os.path.splitext(actual_output)[1].lstrip("."),
"method": "blender-headless",
"blender_version": get_version(),
"file_size": os.path.getsize(actual_output),
"output": result["output"],
"format": result["format"],
"method": result["method"],
"blender_version": result["blender_version"],
"file_size": result["file_size"],
}
finally:
os.unlink(script_path)