Files
HartOMat/backend/tests/domains/test_render_blender_samples.py
T

575 lines
20 KiB
Python

from __future__ import annotations
import importlib.util
import selectors
import sys
from pathlib import Path
from types import SimpleNamespace
import pytest
def test_resolve_render_samples_uses_system_settings_when_omitted(monkeypatch):
from app.services.render_blender import _resolve_render_samples
monkeypatch.setattr(
"app.services.step_processor._get_all_settings",
lambda: {
"blender_cycles_samples": "32",
"blender_eevee_samples": "12",
},
)
assert _resolve_render_samples("cycles", None) == 32
assert _resolve_render_samples("eevee", None) == 12
assert _resolve_render_samples("cycles", 48) == 48
def test_resolve_tessellation_settings_uses_profile_specific_values(monkeypatch):
from app.services.render_blender import resolve_tessellation_settings
monkeypatch.setattr(
"app.services.step_processor._get_all_settings",
lambda: {
"tessellation_engine": "occ",
"scene_linear_deflection": "0.1",
"scene_angular_deflection": "0.1",
"render_linear_deflection": "0.03",
"render_angular_deflection": "0.05",
},
)
assert resolve_tessellation_settings("scene") == (0.1, 0.1, "occ")
assert resolve_tessellation_settings("render") == (0.03, 0.05, "occ")
def test_render_still_passes_resolved_samples_to_blender_cli(tmp_path, monkeypatch):
from app.services.render_blender import build_tessellated_glb_path, render_still
step_path = tmp_path / "bearing.step"
step_path.write_text("STEP", encoding="utf-8")
glb_path = build_tessellated_glb_path(step_path, "render", "occ", 0.03, 0.05)
glb_path.parent.mkdir(parents=True, exist_ok=True)
glb_path.write_text("GLB", encoding="utf-8")
output_path = tmp_path / "render.png"
output_path.write_text("PNG", encoding="utf-8")
scripts_dir = tmp_path / "render-scripts"
scripts_dir.mkdir()
(scripts_dir / "blender_render.py").write_text("# test stub\n", encoding="utf-8")
captured: dict[str, object] = {}
class _FakeProc:
def __init__(self) -> None:
self.stdout = object()
self.stderr = object()
self.pid = 1234
self.returncode = 0
def wait(self, timeout: int | None = None) -> int:
del timeout
return self.returncode
def wait(self, timeout: int | None = None) -> int:
del timeout
return self.returncode
def wait(self, timeout: int = 10) -> int:
return self.returncode
class _FakeSelector:
def register(self, *_args, **_kwargs) -> None:
return None
def get_map(self) -> dict:
return {}
def close(self) -> None:
return None
def _fake_popen(cmd, stdout, stderr, text, env, start_new_session):
captured["cmd"] = cmd
captured["env"] = env
return _FakeProc()
monkeypatch.setenv("RENDER_SCRIPTS_DIR", str(scripts_dir))
monkeypatch.setattr("app.services.render_blender.find_blender", lambda: "/usr/bin/blender")
monkeypatch.setattr("app.services.render_blender.ensure_group_writable_dir", lambda _path: None)
monkeypatch.setattr("app.services.render_blender._resolve_render_samples", lambda engine, samples: 32)
monkeypatch.setattr("app.services.render_blender.subprocess.Popen", _fake_popen)
monkeypatch.setattr(selectors, "DefaultSelector", _FakeSelector)
result = render_still(
step_path=step_path,
output_path=output_path,
engine="cycles",
samples=None,
width=640,
height=480,
)
assert captured["cmd"][10] == "32"
assert captured["env"]["BLENDER_DEFAULT_SAMPLES"] == "32"
assert result["engine_used"] == "cycles"
def test_render_still_passes_template_inputs_to_blender_cli(tmp_path, monkeypatch):
from app.services.render_blender import build_tessellated_glb_path, render_still
step_path = tmp_path / "bearing.step"
step_path.write_text("STEP", encoding="utf-8")
glb_path = build_tessellated_glb_path(step_path, "render", "occ", 0.03, 0.05)
glb_path.parent.mkdir(parents=True, exist_ok=True)
glb_path.write_text("GLB", encoding="utf-8")
output_path = tmp_path / "render.png"
output_path.write_text("PNG", encoding="utf-8")
scripts_dir = tmp_path / "render-scripts"
scripts_dir.mkdir()
(scripts_dir / "blender_render.py").write_text("# test stub\n", encoding="utf-8")
captured: dict[str, object] = {}
class _FakeProc:
def __init__(self) -> None:
self.stdout = object()
self.stderr = object()
self.pid = 1234
self.returncode = 0
def wait(self, timeout: int = 10) -> int:
return self.returncode
class _FakeSelector:
def register(self, *_args, **_kwargs) -> None:
return None
def get_map(self) -> dict:
return {}
def close(self) -> None:
return None
def _fake_popen(cmd, stdout, stderr, text, env, start_new_session):
captured["cmd"] = cmd
return _FakeProc()
monkeypatch.setenv("RENDER_SCRIPTS_DIR", str(scripts_dir))
monkeypatch.setattr("app.services.render_blender.find_blender", lambda: "/usr/bin/blender")
monkeypatch.setattr("app.services.render_blender.ensure_group_writable_dir", lambda _path: None)
monkeypatch.setattr("app.services.render_blender._resolve_render_samples", lambda engine, samples: 32)
monkeypatch.setattr("app.services.render_blender.subprocess.Popen", _fake_popen)
monkeypatch.setattr(selectors, "DefaultSelector", _FakeSelector)
render_still(
step_path=step_path,
output_path=output_path,
engine="cycles",
samples=None,
width=640,
height=480,
template_inputs={"studio_variant": "warm"},
)
assert "--template-inputs" in captured["cmd"]
idx = captured["cmd"].index("--template-inputs")
assert captured["cmd"][idx + 1] == '{"studio_variant": "warm"}'
def test_render_still_uses_settings_sensitive_render_glb_path(tmp_path, monkeypatch):
from app.services.render_blender import build_tessellated_glb_path, render_still
step_path = tmp_path / "bearing.step"
step_path.write_text("STEP", encoding="utf-8")
output_path = tmp_path / "render.png"
output_path.write_text("PNG", encoding="utf-8")
scripts_dir = tmp_path / "render-scripts"
scripts_dir.mkdir()
(scripts_dir / "blender_render.py").write_text("# test stub\n", encoding="utf-8")
captured: dict[str, object] = {}
class _FakeProc:
def __init__(self) -> None:
self.stdout = object()
self.stderr = object()
self.pid = 1234
self.returncode = 0
def wait(self, timeout: int = 10) -> int:
return self.returncode
class _FakeSelector:
def register(self, *_args, **_kwargs) -> None:
return None
def get_map(self) -> dict:
return {}
def close(self) -> None:
return None
def _fake_glb_from_step(step_path, glb_path, tessellation_engine="occ", tessellation_profile="render"):
captured["glb_path"] = glb_path
captured["tessellation_engine"] = tessellation_engine
captured["tessellation_profile"] = tessellation_profile
glb_path.write_text("GLB", encoding="utf-8")
def _fake_popen(cmd, stdout, stderr, text, env, start_new_session):
captured["cmd"] = cmd
return _FakeProc()
monkeypatch.setenv("RENDER_SCRIPTS_DIR", str(scripts_dir))
monkeypatch.setattr("app.services.render_blender.find_blender", lambda: "/usr/bin/blender")
monkeypatch.setattr("app.services.render_blender.ensure_group_writable_dir", lambda _path: None)
monkeypatch.setattr("app.services.render_blender._resolve_render_samples", lambda engine, samples: 32)
monkeypatch.setattr(
"app.services.step_processor._get_all_settings",
lambda: {
"tessellation_engine": "occ",
"render_linear_deflection": "0.03",
"render_angular_deflection": "0.05",
"blender_cycles_samples": "32",
"blender_eevee_samples": "12",
},
)
monkeypatch.setattr("app.services.render_blender._glb_from_step", _fake_glb_from_step)
monkeypatch.setattr("app.services.render_blender.subprocess.Popen", _fake_popen)
monkeypatch.setattr(selectors, "DefaultSelector", _FakeSelector)
render_still(
step_path=step_path,
output_path=output_path,
engine="cycles",
samples=None,
width=640,
height=480,
)
expected_glb_path = build_tessellated_glb_path(step_path, "render", "occ", 0.03, 0.05)
assert captured["glb_path"] == expected_glb_path
assert captured["tessellation_profile"] == "render"
assert captured["cmd"][5] == str(expected_glb_path)
def test_render_turntable_passes_template_inputs_to_blender_cli(tmp_path, monkeypatch):
from app.services.render_blender import build_tessellated_glb_path, render_turntable_to_file
step_path = tmp_path / "bearing.step"
step_path.write_text("STEP", encoding="utf-8")
glb_path = build_tessellated_glb_path(step_path, "render", "occ", 0.03, 0.05)
glb_path.parent.mkdir(parents=True, exist_ok=True)
glb_path.write_text("GLB", encoding="utf-8")
output_path = tmp_path / "turntable.mp4"
output_path.parent.mkdir(parents=True, exist_ok=True)
scripts_dir = tmp_path / "render-scripts"
scripts_dir.mkdir()
(scripts_dir / "turntable_render.py").write_text("# test stub\n", encoding="utf-8")
captured: dict[str, object] = {}
class _FakeProc:
def __init__(self) -> None:
self.pid = 1234
self.returncode = 0
def communicate(self, timeout: int | None = None) -> tuple[str, str]:
frames_dir = Path(captured["cmd"][6])
frames_dir.mkdir(parents=True, exist_ok=True)
(frames_dir / "frame_0001.png").write_text("PNG", encoding="utf-8")
return ("[turntable_render] ok\n", "")
def _fake_popen(cmd, stdout, stderr, text, env, start_new_session):
captured["cmd"] = cmd
return _FakeProc()
def _fake_ffmpeg(cmd, capture_output, text, timeout):
output_path.write_text("MP4", encoding="utf-8")
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setenv("RENDER_SCRIPTS_DIR", str(scripts_dir))
monkeypatch.setattr("app.services.render_blender.find_blender", lambda: "/usr/bin/blender")
monkeypatch.setattr("app.services.render_blender.ensure_group_writable_dir", lambda _path: None)
monkeypatch.setattr("app.services.render_blender.subprocess.Popen", _fake_popen)
monkeypatch.setattr("app.services.render_blender.subprocess.run", _fake_ffmpeg)
monkeypatch.setattr("app.services.render_blender.build_turntable_ffmpeg_cmd", lambda *args, **kwargs: ["ffmpeg", str(output_path)])
monkeypatch.setattr("app.services.render_blender.resolve_tessellation_settings", lambda *args, **kwargs: (0.03, 0.05, "occ"))
render_turntable_to_file(
step_path=step_path,
output_path=output_path,
engine="cycles",
samples=32,
template_inputs={"studio_variant": "warm"},
)
assert "--template-inputs" in captured["cmd"]
idx = captured["cmd"].index("--template-inputs")
assert captured["cmd"][idx + 1] == '{"studio_variant": "warm"}'
def test_render_cinematic_passes_template_inputs_to_blender_cli(tmp_path, monkeypatch):
from app.services.render_blender import build_tessellated_glb_path, render_cinematic_to_file
step_path = tmp_path / "bearing.step"
step_path.write_text("STEP", encoding="utf-8")
glb_path = build_tessellated_glb_path(step_path, "render", "occ", 0.03, 0.05)
glb_path.parent.mkdir(parents=True, exist_ok=True)
glb_path.write_text("GLB", encoding="utf-8")
output_path = tmp_path / "cinematic.mp4"
output_path.parent.mkdir(parents=True, exist_ok=True)
scripts_dir = tmp_path / "render-scripts"
scripts_dir.mkdir()
(scripts_dir / "cinematic_render.py").write_text("# test stub\n", encoding="utf-8")
captured: dict[str, object] = {}
class _FakeProc:
def __init__(self) -> None:
self.stdout = object()
self.stderr = object()
self.pid = 1234
self.returncode = 0
def wait(self, timeout: int | None = None) -> int:
del timeout
return self.returncode
class _FakeSelector:
def __init__(self) -> None:
self._registered: list[object] = []
self._delivered = False
def register(self, fileobj, _event, data):
self._registered.append((fileobj, data))
def unregister(self, fileobj):
self._registered = [item for item in self._registered if item[0] is not fileobj]
def get_map(self) -> dict[int, object]:
return {idx: item for idx, item in enumerate(self._registered)}
def select(self, timeout=None):
del timeout
if self._delivered:
for fileobj, _data in list(self._registered):
if hasattr(fileobj, "readline"):
fileobj.readline = lambda: ""
self._registered.clear()
return []
self._delivered = True
events = []
for fileobj, data in list(self._registered):
events.append((SimpleNamespace(fileobj=fileobj, data=data), None))
return events
def close(self):
return None
class _FakeStream:
def __init__(self, lines: list[str]) -> None:
self._lines = list(lines)
def readline(self) -> str:
if not self._lines:
return ""
return self._lines.pop(0)
def _fake_popen(cmd, stdout, stderr, text, env, start_new_session):
captured["cmd"] = cmd
frames_dir = Path(cmd[6])
frames_dir.mkdir(parents=True, exist_ok=True)
(frames_dir / "frame_0001.png").write_text("PNG", encoding="utf-8")
proc = _FakeProc()
proc.stdout = _FakeStream(["[cinematic_render] ok\n"])
proc.stderr = _FakeStream([])
return proc
def _fake_ffmpeg(cmd, capture_output, text, timeout):
output_path.write_text("MP4", encoding="utf-8")
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setenv("RENDER_SCRIPTS_DIR", str(scripts_dir))
monkeypatch.setattr("app.services.render_blender.find_blender", lambda: "/usr/bin/blender")
monkeypatch.setattr("app.services.render_blender.ensure_group_writable_dir", lambda _path: None)
monkeypatch.setattr("app.services.render_blender.subprocess.Popen", _fake_popen)
monkeypatch.setattr("app.services.render_blender.subprocess.run", _fake_ffmpeg)
monkeypatch.setattr("app.services.render_blender.build_turntable_ffmpeg_cmd", lambda *args, **kwargs: ["ffmpeg", str(output_path)])
monkeypatch.setattr("app.services.render_blender.resolve_tessellation_settings", lambda *args, **kwargs: (0.03, 0.05, "occ"))
monkeypatch.setattr("selectors.DefaultSelector", _FakeSelector)
render_cinematic_to_file(
step_path=step_path,
output_path=output_path,
engine="cycles",
samples=32,
template_inputs={"studio_variant": "warm"},
)
assert "--template-inputs" in captured["cmd"]
idx = captured["cmd"].index("--template-inputs")
assert captured["cmd"][idx + 1] == '{"studio_variant": "warm"}'
def test_render_still_task_keeps_samples_unset_until_render_service(tmp_path, monkeypatch):
from app.domains.rendering.tasks import render_still_task
step_path = tmp_path / "bearing.step"
step_path.write_text("STEP", encoding="utf-8")
output_path = tmp_path / "render.png"
captured: dict[str, object] = {}
def _fake_render_still(**kwargs):
captured.update(kwargs)
return {"total_duration_s": 0.1}
monkeypatch.setattr("app.domains.rendering.tasks.log_task_event", lambda *args, **kwargs: None)
monkeypatch.setattr("app.services.render_blender.render_still", _fake_render_still)
task_self = SimpleNamespace(
request=SimpleNamespace(id="task-still"),
retry=lambda *, exc, countdown: (_ for _ in ()).throw(exc),
)
result = render_still_task.run.__func__(task_self, str(step_path), str(output_path))
assert captured["samples"] is None
assert result["total_duration_s"] == 0.1
def test_blender_args_prefers_backend_default_samples_env(monkeypatch):
module_path = (
Path(__file__).resolve().parents[2]
/ "render-worker"
/ "scripts"
/ "_blender_args.py"
)
if not module_path.exists():
pytest.skip(f"{module_path} not present in this runtime")
spec = importlib.util.spec_from_file_location("test_blender_args_module", module_path)
assert spec is not None
assert spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
monkeypatch.setenv("BLENDER_DEFAULT_SAMPLES", "32")
monkeypatch.setattr(
sys,
"argv",
[
"blender_render.py",
"--",
"input.glb",
"output.png",
"512",
"512",
"cycles",
"",
],
)
args = module.parse_args()
assert args.samples == 32
def test_blender_args_parses_template_inputs(monkeypatch):
module_path = (
Path(__file__).resolve().parents[2]
/ "render-worker"
/ "scripts"
/ "_blender_args.py"
)
if not module_path.exists():
pytest.skip(f"{module_path} not present in this runtime")
spec = importlib.util.spec_from_file_location("test_blender_args_module_template_inputs", module_path)
assert spec is not None
assert spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
monkeypatch.setattr(
sys,
"argv",
[
"blender_render.py",
"--",
"input.glb",
"output.png",
"512",
"512",
"cycles",
"64",
"30",
"auto",
"0",
"",
"Product",
"",
"{}",
"[]",
"0",
"0",
"0",
"0",
"",
"",
"",
"",
"",
"",
"--template-inputs",
'{"studio_variant":"warm"}',
],
)
args = module.parse_args()
assert args.template_inputs == {"studio_variant": "warm"}
def test_render_to_file_preserves_explicit_zero_samples(tmp_path, monkeypatch):
from app.services.step_processor import render_to_file
step_path = tmp_path / "bearing.step"
step_path.write_text("STEP", encoding="utf-8")
output_path = tmp_path / "render.png"
captured: dict[str, object] = {}
monkeypatch.setattr(
"app.services.step_processor._get_all_settings",
lambda: {
"thumbnail_renderer": "blender",
"thumbnail_format": "png",
"blender_engine": "cycles",
"blender_cycles_samples": "32",
"blender_eevee_samples": "12",
"cycles_device": "auto",
"blender_smooth_angle": "30",
"tessellation_engine": "occ",
},
)
monkeypatch.setattr("app.services.step_processor.ensure_group_writable_dir", lambda _path: None)
monkeypatch.setattr("app.services.render_blender.is_blender_available", lambda: True)
def _fake_render_still(**kwargs):
captured.update(kwargs)
kwargs["output_path"].write_text("PNG", encoding="utf-8")
return {"total_duration_s": 0.1, "engine_used": kwargs["engine"]}
monkeypatch.setattr("app.services.render_blender.render_still", _fake_render_still)
success, render_log = render_to_file(
str(step_path),
str(output_path),
samples=0,
)
assert success is True
assert captured["samples"] == 0
assert render_log["samples"] == 0