mirror of
https://github.com/HKUDS/CLI-Anything.git
synced 2026-08-28 23:27:04 +08:00
fix(blender): align docs and render execute with real behavior
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user