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