From d676e019cac857423eea43c1ee52d72bf53e091c Mon Sep 17 00:00:00 2001 From: yuhao Date: Tue, 21 Apr 2026 06:38:35 +0000 Subject: [PATCH] fix(blender): align docs and render execute with real behavior --- README.md | 9 +- README_CN.md | 9 +- .../cli_anything/blender/README.md | 95 ++++++------ .../cli_anything/blender/blender_cli.py | 142 +++++++++++++----- .../cli_anything/blender/core/render.py | 41 ++++- .../blender/tests/test_full_e2e.py | 74 +++++++-- .../blender/utils/blender_backend.py | 94 ++++++++---- 7 files changed, 328 insertions(+), 136 deletions(-) diff --git a/README.md b/README.md index a7838e0c9..53a086bd7 100644 --- a/README.md +++ b/README.md @@ -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! 👋 diff --git a/README_CN.md b/README_CN.md index a8ca794ee..3f3993441 100644 --- a/README_CN.md +++ b/README_CN.md @@ -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! 👋 diff --git a/blender/agent-harness/cli_anything/blender/README.md b/blender/agent-harness/cli_anything/blender/README.md index acec7b53f..0ac20eeb2 100644 --- a/blender/agent-harness/cli_anything/blender/README.md +++ b/blender/agent-harness/cli_anything/blender/README.md @@ -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. diff --git a/blender/agent-harness/cli_anything/blender/blender_cli.py b/blender/agent-harness/cli_anything/blender/blender_cli.py index bea340bc0..4b0c76fc2 100644 --- a/blender/agent-harness/cli_anything/blender/blender_cli.py +++ b/blender/agent-harness/cli_anything/blender/blender_cli.py @@ -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") diff --git a/blender/agent-harness/cli_anything/blender/core/render.py b/blender/agent-harness/cli_anything/blender/core/render.py index cb908a273..b63c59359 100644 --- a/blender/agent-harness/cli_anything/blender/core/render.py +++ b/blender/agent-harness/cli_anything/blender/core/render.py @@ -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 diff --git a/blender/agent-harness/cli_anything/blender/tests/test_full_e2e.py b/blender/agent-harness/cli_anything/blender/tests/test_full_e2e.py index 38f781053..335d65ad5 100644 --- a/blender/agent-harness/cli_anything/blender/tests/test_full_e2e.py +++ b/blender/agent-harness/cli_anything/blender/tests/test_full_e2e.py @@ -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 diff --git a/blender/agent-harness/cli_anything/blender/utils/blender_backend.py b/blender/agent-harness/cli_anything/blender/utils/blender_backend.py index 4a42de801..d27a65b08 100644 --- a/blender/agent-harness/cli_anything/blender/utils/blender_backend.py +++ b/blender/agent-harness/cli_anything/blender/utils/blender_backend.py @@ -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)