575 lines
20 KiB
Python
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
|