Files
HartOMat/backend/app/services/render_blender.py
T

1008 lines
35 KiB
Python

"""Direct Blender rendering service — runs Blender as a subprocess.
Used by the render-worker Celery container (which has BLENDER_BIN set and
cadquery installed). The backend and standard workers fall back to the Pillow
placeholder when this service is unavailable.
"""
import hashlib
import json
import logging
import os
import shutil
import signal
import subprocess
from pathlib import Path
from app.core.render_paths import ensure_group_writable_dir
logger = logging.getLogger(__name__)
def resolve_tessellation_settings(
profile: str = "render",
tessellation_engine: str | None = None,
) -> tuple[float, float, str]:
"""Resolve tessellation settings from system settings for a given profile."""
profile_key = "scene" if profile == "scene" else "render"
defaults = {
"scene": (0.1, 0.1),
"render": (0.03, 0.05),
}
default_linear, default_angular = defaults[profile_key]
try:
from app.services.step_processor import _get_all_settings
settings = _get_all_settings()
linear_deflection = float(
settings.get(f"{profile_key}_linear_deflection", str(default_linear))
)
angular_deflection = float(
settings.get(f"{profile_key}_angular_deflection", str(default_angular))
)
effective_engine = (
tessellation_engine
or settings.get("tessellation_engine", "occ")
or "occ"
)
return linear_deflection, angular_deflection, effective_engine
except Exception as exc:
logger.warning(
"Could not resolve %s tessellation settings: %s; using defaults",
profile_key,
exc,
)
return default_linear, default_angular, tessellation_engine or "occ"
def build_tessellated_glb_path(
step_path: Path,
profile: str,
tessellation_engine: str,
linear_deflection: float,
angular_deflection: float,
) -> Path:
"""Build a settings-sensitive GLB path to avoid stale mesh reuse."""
signature = hashlib.sha1(
f"{profile}:{tessellation_engine}:{linear_deflection:.6f}:{angular_deflection:.6f}".encode(
"utf-8"
)
).hexdigest()[:10]
return step_path.parent / f"{step_path.stem}_{profile}_{signature}.glb"
def _stringify_optional_arg(value: object) -> str:
if value in (None, ""):
return ""
return str(value)
def _resolve_render_samples(engine: str, samples: int | None) -> int:
if samples is not None:
return int(samples)
effective_engine = (engine or "cycles").lower()
setting_key = (
"blender_eevee_samples"
if effective_engine == "eevee"
else "blender_cycles_samples"
)
try:
from app.services.step_processor import _get_all_settings
settings = _get_all_settings()
return int(settings[setting_key])
except Exception as exc:
logger.warning(
"Could not resolve Blender samples from settings for engine=%s: %s; "
"using legacy fallback",
effective_engine,
exc,
)
return 64 if effective_engine == "eevee" else 256
def build_turntable_ffmpeg_cmd(
frames_dir: Path,
output_path: Path,
*,
fps: int = 30,
bg_color: str = "",
width: int = 1920,
height: int = 1080,
ffmpeg_bin: str | None = None,
) -> list[str]:
"""Build the canonical FFmpeg command for turntable MP4 composition.
Legacy and graph/shadow paths must share this logic so template-backed
turntable outputs do not drift due to encoding differences.
"""
ffmpeg = ffmpeg_bin or shutil.which("ffmpeg") or "ffmpeg"
if any(frames_dir.glob("frame_*.png")):
frame_pattern = str(frames_dir / "frame_%04d.png")
else:
frame_pattern = str(frames_dir / "%04d.png")
if bg_color:
hex_color = bg_color.lstrip("#") or "ffffff"
return [
ffmpeg,
"-y",
"-framerate",
str(fps),
"-i",
frame_pattern,
"-f",
"lavfi",
"-i",
f"color=c=0x{hex_color}:size={width}x{height}:rate={fps}",
"-filter_complex",
"[1:v][0:v]overlay=0:0:shortest=1",
"-vcodec",
"libx264",
"-pix_fmt",
"yuv420p",
"-crf",
"18",
"-movflags",
"+faststart",
str(output_path),
]
return [
ffmpeg,
"-y",
"-framerate",
str(fps),
"-i",
frame_pattern,
"-vcodec",
"libx264",
"-pix_fmt",
"yuv420p",
"-crf",
"18",
"-movflags",
"+faststart",
str(output_path),
]
def _glb_from_step(
step_path: Path,
glb_path: Path,
tessellation_engine: str = "occ",
tessellation_profile: str = "render",
) -> None:
"""Convert STEP → GLB via OCC or GMSH (export_step_to_gltf.py, no Blender needed)."""
import subprocess
import sys as _sys
linear_deflection, angular_deflection, effective_engine = resolve_tessellation_settings(
tessellation_profile,
tessellation_engine,
)
scripts_dir = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
script_path = scripts_dir / "export_step_to_gltf.py"
cmd = [
_sys.executable, str(script_path),
"--step_path", str(step_path),
"--output_path", str(glb_path),
"--linear_deflection", str(linear_deflection),
"--angular_deflection", str(angular_deflection),
"--tessellation_engine", effective_engine,
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
for line in result.stdout.splitlines():
logger.info("[export-gltf] %s", line)
for line in result.stderr.splitlines():
logger.warning("[export-gltf stderr] %s", line)
if result.returncode != 0 or not glb_path.exists() or glb_path.stat().st_size == 0:
raise RuntimeError(
f"export_step_to_gltf.py failed (exit {result.returncode}).\n"
f"STDERR: {result.stderr[-1000:]}"
)
logger.info(
"GLB converted: %s (%d KB) with %s tessellation linear=%s angular=%s engine=%s",
glb_path.name,
glb_path.stat().st_size // 1024,
tessellation_profile,
linear_deflection,
angular_deflection,
effective_engine,
)
def find_blender() -> str:
"""Locate the Blender binary via $BLENDER_BIN or PATH."""
env_bin = os.environ.get("BLENDER_BIN", "")
if env_bin and Path(env_bin).exists():
return env_bin
found = shutil.which("blender")
return found or ""
def is_blender_available() -> bool:
"""Return True if a Blender binary is reachable from this process."""
return bool(find_blender())
def render_still(
step_path: Path,
output_path: Path,
width: int = 512,
height: int = 512,
engine: str = "cycles",
samples: int | None = None,
smooth_angle: int = 30,
cycles_device: str = "gpu",
transparent_bg: bool = False,
part_colors: dict | None = None,
template_path: str | None = None,
target_collection: str = "Product",
material_library_path: str | None = None,
material_map: dict | None = None,
part_names_ordered: list | None = None,
lighting_only: bool = False,
shadow_catcher: bool = False,
rotation_x: float = 0.0,
rotation_y: float = 0.0,
rotation_z: float = 0.0,
noise_threshold: str = "",
denoiser: str = "",
denoising_input_passes: str = "",
denoising_prefilter: str = "",
denoising_quality: str = "",
denoising_use_gpu: str = "",
mesh_attributes: dict | None = None,
log_callback: "Callable[[str], None] | None" = None,
usd_path: "Path | None" = None,
tessellation_engine: str = "occ",
tessellation_profile: str = "render",
focal_length_mm: float | None = None,
sensor_width_mm: float | None = None,
material_override: str | None = None,
template_inputs: dict | None = None,
**ignored_control_kwargs,
) -> dict:
"""Convert STEP → GLB (OCC or GMSH) → PNG (Blender subprocess).
When usd_path is provided and the file exists, the GLB conversion step is
skipped and Blender imports the USD stage directly (--usd-path flag).
Returns a dict with timing, sizes, engine_used, and log_lines.
Raises RuntimeError on failure.
"""
import time
blender_bin = find_blender()
if not blender_bin:
raise RuntimeError("Blender binary not found — check BLENDER_BIN env or PATH")
script_path = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts")) / "blender_render.py"
if not script_path.exists():
alt = Path(__file__).parent.parent.parent.parent / "render-worker" / "scripts" / "blender_render.py"
if alt.exists():
script_path = alt
else:
raise RuntimeError(f"blender_render.py not found at {script_path}")
t0 = time.monotonic()
if ignored_control_kwargs:
logger.debug(
"render_still ignoring unsupported control kwargs: %s",
sorted(ignored_control_kwargs.keys()),
)
if isinstance(usd_path, str) and usd_path.strip():
usd_path = Path(usd_path)
actual_samples = _resolve_render_samples(engine, samples)
# 1. GLB conversion (OCC) — skipped when usd_path is provided
use_usd = bool(usd_path and usd_path.exists())
t_glb = time.monotonic()
if use_usd:
logger.info("[render_blender] using USD path: %s", usd_path)
glb_size_bytes = 0
else:
linear_deflection, angular_deflection, effective_engine = resolve_tessellation_settings(
tessellation_profile,
tessellation_engine,
)
glb_path = build_tessellated_glb_path(
step_path,
tessellation_profile,
effective_engine,
linear_deflection,
angular_deflection,
)
if not glb_path.exists() or glb_path.stat().st_size == 0:
_glb_from_step(
step_path,
glb_path,
tessellation_engine=effective_engine,
tessellation_profile=tessellation_profile,
)
else:
logger.info(
"GLB local hit: %s (%d KB) profile=%s linear=%s angular=%s engine=%s",
glb_path.name,
glb_path.stat().st_size // 1024,
tessellation_profile,
linear_deflection,
angular_deflection,
effective_engine,
)
glb_size_bytes = glb_path.stat().st_size if glb_path.exists() else 0
glb_duration_s = round(time.monotonic() - t_glb, 2)
# 2. Blender render
ensure_group_writable_dir(output_path.parent)
env = dict(os.environ)
if engine == "eevee":
env.update({
"VK_ICD_FILENAMES": "/usr/share/vulkan/icd.d/lvp_icd.x86_64.json",
"LIBGL_ALWAYS_SOFTWARE": "1",
"MESA_GL_VERSION_OVERRIDE": "4.5",
"EGL_PLATFORM": "surfaceless",
})
else:
env["EGL_PLATFORM"] = "surfaceless"
env["BLENDER_DEFAULT_SAMPLES"] = str(actual_samples)
def _build_cmd(eng: str) -> list:
# Pass "" as glb_path when using USD — blender_render.py reads --usd-path instead
glb_arg = "" if use_usd else str(glb_path)
cmd = [
blender_bin,
"--background",
"--python", str(script_path),
"--",
glb_arg,
str(output_path),
str(width), str(height),
eng, str(actual_samples), str(smooth_angle),
cycles_device,
"1" if transparent_bg else "0",
template_path or "",
target_collection,
material_library_path or "",
json.dumps(material_map) if material_map else "{}",
json.dumps(part_names_ordered) if part_names_ordered else "[]",
"1" if lighting_only else "0",
"1" if shadow_catcher else "0",
str(rotation_x), str(rotation_y), str(rotation_z),
_stringify_optional_arg(noise_threshold), _stringify_optional_arg(denoiser),
_stringify_optional_arg(denoising_input_passes), _stringify_optional_arg(denoising_prefilter),
_stringify_optional_arg(denoising_quality), _stringify_optional_arg(denoising_use_gpu),
]
if use_usd:
cmd += ["--usd-path", str(usd_path)]
if mesh_attributes:
logger.debug("[render_blender] usd_path active — mesh_attributes ignored")
elif mesh_attributes:
cmd += ["--mesh-attributes", json.dumps(mesh_attributes)]
if focal_length_mm is not None:
cmd += ["--focal-length", str(focal_length_mm)]
if sensor_width_mm is not None:
cmd += ["--sensor-width", str(sensor_width_mm)]
if material_override:
cmd += ["--material-override", material_override]
if template_inputs:
cmd += ["--template-inputs", json.dumps(template_inputs)]
return cmd
def _run(eng: str) -> tuple[int, list[str], list[str]]:
"""Run Blender subprocess, streaming stdout line-by-line.
Returns (returncode, stdout_lines, stderr_lines).
"""
import selectors
proc = subprocess.Popen(
_build_cmd(eng),
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, env=env, start_new_session=True,
)
stdout_lines: list[str] = []
stderr_lines: list[str] = []
deadline = time.monotonic() + 600
sel = selectors.DefaultSelector()
sel.register(proc.stdout, selectors.EVENT_READ, "stdout")
sel.register(proc.stderr, selectors.EVENT_READ, "stderr")
try:
while sel.get_map():
remaining = deadline - time.monotonic()
if remaining <= 0:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
except (ProcessLookupError, OSError):
pass
break
events = sel.select(timeout=min(remaining, 2.0))
for key, _ in events:
line = key.fileobj.readline()
if not line:
sel.unregister(key.fileobj)
continue
line = line.rstrip("\n")
if key.data == "stdout":
stdout_lines.append(line)
logger.info("[blender] %s", line)
if log_callback and "[blender_render]" in line:
log_callback(line)
else:
stderr_lines.append(line)
logger.warning("[blender stderr] %s", line)
finally:
sel.close()
proc.wait(timeout=10)
return proc.returncode, stdout_lines, stderr_lines
t_render = time.monotonic()
returncode, stdout_lines, stderr_lines = _run(engine)
engine_used = engine
log_lines = [l for l in stdout_lines if "[blender_render]" in l]
# Parse RENDER_DEVICE_USED token from stdout
device_used = "unknown"
compute_type = "unknown"
gpu_fallback = False
for line in stdout_lines:
if line.startswith("RENDER_DEVICE_USED:"):
parts = line.split()
for part in parts:
if part.startswith("device="):
device_used = part.split("=", 1)[1]
elif part.startswith("compute_type="):
compute_type = part.split("=", 1)[1]
gpu_fallback = (device_used == "CPU")
break
# EEVEE fallback removed (Phase 5.2): EEVEE Next in Blender 5.0+ is stable.
# If EEVEE fails, it is a hard failure — no silent retry.
if returncode == 2:
raise RuntimeError(
"GPU required but render used CPU — strict mode (CYCLES_DEVICE=gpu). "
"Check that the render-worker has a visible NVIDIA GPU."
)
if returncode != 0:
stdout_tail = "\n".join(stdout_lines[-50:]) if stdout_lines else ""
stderr_tail = "\n".join(stderr_lines[-20:]) if stderr_lines else ""
raise RuntimeError(
f"Blender exited with code {returncode}.\n"
f"stdout: {stdout_tail[-2000:]}\n"
f"stderr: {stderr_tail[-500:]}"
)
render_duration_s = round(time.monotonic() - t_render, 2)
return {
"total_duration_s": round(time.monotonic() - t0, 2),
"stl_duration_s": glb_duration_s, # key kept for backward compat with DB render_log
"render_duration_s": render_duration_s,
"stl_size_bytes": glb_size_bytes,
"output_size_bytes": output_path.stat().st_size if output_path.exists() else 0,
"parts_count": 0,
"engine_used": engine_used,
"device_used": device_used,
"compute_type": compute_type,
"gpu_fallback": gpu_fallback,
"log_lines": log_lines,
}
def render_turntable_to_file(
step_path: Path,
output_path: Path,
frame_count: int = 24,
fps: int = 25,
width: int = 1920,
height: int = 1920,
engine: str = "cycles",
samples: int = 128,
smooth_angle: int = 30,
cycles_device: str = "gpu",
transparent_bg: bool = False,
bg_color: str = "",
turntable_axis: str = "world_z",
part_colors: dict | None = None,
template_path: str | None = None,
target_collection: str = "Product",
material_library_path: str | None = None,
material_map: dict | None = None,
part_names_ordered: list | None = None,
lighting_only: bool = False,
shadow_catcher: bool = False,
rotation_x: float = 0.0,
rotation_y: float = 0.0,
rotation_z: float = 0.0,
camera_orbit: bool = True,
usd_path: "Path | None" = None,
tessellation_engine: str = "occ",
tessellation_profile: str = "render",
focal_length_mm: float | None = None,
sensor_width_mm: float | None = None,
material_override: str | None = None,
template_inputs: dict | None = None,
) -> dict:
"""Render a turntable animation: STEP → STL → N frames (Blender) → mp4 (ffmpeg).
When usd_path is provided and exists, the GLB conversion step is skipped.
Returns a dict with timing, frame count, engine_used, log_lines.
Raises RuntimeError on failure.
"""
import shutil as _shutil
import tempfile
import time
blender_bin = find_blender()
if not blender_bin:
raise RuntimeError("Blender binary not found — check BLENDER_BIN env or PATH")
script_path = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts")) / "turntable_render.py"
if not script_path.exists():
alt = Path(__file__).parent.parent.parent.parent / "render-worker" / "scripts" / "turntable_render.py"
if alt.exists():
script_path = alt
else:
raise RuntimeError(f"turntable_render.py not found at {script_path}")
ffmpeg_bin = _shutil.which("ffmpeg")
if not ffmpeg_bin:
raise RuntimeError("ffmpeg not found — install ffmpeg in the render-worker container")
t0 = time.monotonic()
# 1. GLB conversion (OCC) — skipped when usd_path is provided
use_usd = bool(usd_path and usd_path.exists())
t_glb = time.monotonic()
if use_usd:
logger.info("[render_blender] turntable using USD path: %s", usd_path)
else:
linear_deflection, angular_deflection, effective_engine = resolve_tessellation_settings(
tessellation_profile,
tessellation_engine,
)
glb_path = build_tessellated_glb_path(
step_path,
tessellation_profile,
effective_engine,
linear_deflection,
angular_deflection,
)
if not glb_path.exists() or glb_path.stat().st_size == 0:
_glb_from_step(
step_path,
glb_path,
tessellation_engine=effective_engine,
tessellation_profile=tessellation_profile,
)
else:
logger.info(
"GLB local hit: %s (%d KB) profile=%s linear=%s angular=%s engine=%s",
glb_path.name,
glb_path.stat().st_size // 1024,
tessellation_profile,
linear_deflection,
angular_deflection,
effective_engine,
)
glb_duration_s = round(time.monotonic() - t_glb, 2)
# 2. Render frames with Blender
frames_dir = output_path.parent / f"_frames_{output_path.stem}"
if frames_dir.exists():
_shutil.rmtree(frames_dir, ignore_errors=True)
ensure_group_writable_dir(frames_dir)
ensure_group_writable_dir(output_path.parent)
env = dict(os.environ)
env["EGL_PLATFORM"] = "surfaceless"
glb_arg = "" if use_usd else str(glb_path)
cmd = [
blender_bin,
"--background",
"--python", str(script_path),
"--",
glb_arg,
str(frames_dir),
str(frame_count),
"360", # degrees
str(width), str(height),
engine, str(samples),
json.dumps(part_colors or {}),
template_path or "",
target_collection,
material_library_path or "",
json.dumps(material_map) if material_map else "{}",
json.dumps(part_names_ordered) if part_names_ordered else "[]",
"1" if lighting_only else "0",
cycles_device,
"1" if shadow_catcher else "0",
str(rotation_x), str(rotation_y), str(rotation_z),
turntable_axis,
bg_color or "",
"1" if transparent_bg else "0",
]
if camera_orbit:
cmd += ["--camera-orbit"]
if use_usd:
cmd += ["--usd-path", str(usd_path)]
if focal_length_mm is not None:
cmd += ["--focal-length", str(focal_length_mm)]
if sensor_width_mm is not None:
cmd += ["--sensor-width", str(sensor_width_mm)]
if material_override:
cmd += ["--material-override", material_override]
if template_inputs:
cmd += ["--template-inputs", json.dumps(template_inputs)]
log_lines: list[str] = []
t_render = time.monotonic()
proc = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, env=env, start_new_session=True,
)
try:
stdout, stderr = proc.communicate(timeout=3600) # 1hr max for full animation
except subprocess.TimeoutExpired:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
except (ProcessLookupError, OSError):
pass
stdout, stderr = proc.communicate()
for line in (stdout or "").splitlines():
logger.info("[turntable] %s", line)
if "[turntable_render]" in line:
log_lines.append(line)
for line in (stderr or "").splitlines():
logger.warning("[turntable stderr] %s", line)
if proc.returncode != 0:
raise RuntimeError(
f"turntable_render.py exited with code {proc.returncode}.\n"
f"stdout: {(stdout or '')[-2000:]}\n"
f"stderr: {(stderr or '')[-500:]}"
)
render_duration_s = round(time.monotonic() - t_render, 2)
# Check frames were written
frame_files = sorted(frames_dir.glob("frame_*.png"))
if not frame_files:
raise RuntimeError(f"No frames rendered in {frames_dir}")
logger.info("Rendered %d frames in %.1fs", len(frame_files), render_duration_s)
# 3. Compose frames → mp4 with ffmpeg
t_ffmpeg = time.monotonic()
ffmpeg_cmd = build_turntable_ffmpeg_cmd(
frames_dir,
output_path,
fps=fps,
bg_color=bg_color if transparent_bg else "",
width=width,
height=height,
ffmpeg_bin=ffmpeg_bin,
)
ffmpeg_proc = subprocess.run(
ffmpeg_cmd, capture_output=True, text=True, timeout=300
)
ffmpeg_duration_s = round(time.monotonic() - t_ffmpeg, 2)
for line in (ffmpeg_proc.stdout or "").splitlines():
logger.info("[ffmpeg] %s", line)
for line in (ffmpeg_proc.stderr or "").splitlines():
logger.debug("[ffmpeg stderr] %s", line)
if ffmpeg_proc.returncode != 0:
raise RuntimeError(
f"ffmpeg exited with code {ffmpeg_proc.returncode}.\n"
f"stderr: {(ffmpeg_proc.stderr or '')[-1000:]}"
)
# Clean up frames directory
try:
_shutil.rmtree(frames_dir)
except Exception:
pass
return {
"total_duration_s": round(time.monotonic() - t0, 2),
"stl_duration_s": glb_duration_s, # key kept for backward compat with DB render_log
"render_duration_s": render_duration_s,
"ffmpeg_duration_s": ffmpeg_duration_s,
"stl_size_bytes": 0,
"output_size_bytes": output_path.stat().st_size if output_path.exists() else 0,
"frame_count": len(frame_files),
"engine_used": engine,
"log_lines": log_lines,
}
def render_cinematic_to_file(
step_path: Path,
output_path: Path,
width: int = 1920,
height: int = 1080,
engine: str = "cycles",
samples: int = 128,
smooth_angle: int = 30,
cycles_device: str = "gpu",
transparent_bg: bool = False,
part_colors: dict | None = None,
template_path: str | None = None,
target_collection: str = "Product",
material_library_path: str | None = None,
material_map: dict | None = None,
part_names_ordered: list | None = None,
lighting_only: bool = False,
shadow_catcher: bool = False,
rotation_x: float = 0.0,
rotation_y: float = 0.0,
rotation_z: float = 0.0,
usd_path: "Path | None" = None,
tessellation_engine: str = "occ",
tessellation_profile: str = "render",
focal_length_mm: float | None = None,
sensor_width_mm: float | None = None,
material_override: str | None = None,
template_inputs: dict | None = None,
log_callback: "Callable[[str], None] | None" = None,
) -> dict:
"""Render a cinematic highlight animation: STEP -> GLB/USD -> 480 frames @ 24fps (Blender) -> mp4 (ffmpeg).
Fixed at 24fps, 480 frames (20 seconds). Uses cinematic_render.py which
creates a procedural 4-segment camera animation with varying focal lengths,
elevations, and bezier-eased transitions.
When usd_path is provided and exists, the GLB conversion step is skipped.
Returns a dict with timing, frame count, engine_used, log_lines.
Raises RuntimeError on failure.
"""
import shutil as _shutil
import time
# Cinematic parameters are fixed
frame_count = 250
fps = 25
blender_bin = find_blender()
if not blender_bin:
raise RuntimeError("Blender binary not found — check BLENDER_BIN env or PATH")
script_path = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts")) / "cinematic_render.py"
if not script_path.exists():
alt = Path(__file__).parent.parent.parent.parent / "render-worker" / "scripts" / "cinematic_render.py"
if alt.exists():
script_path = alt
else:
raise RuntimeError(f"cinematic_render.py not found at {script_path}")
ffmpeg_bin = _shutil.which("ffmpeg")
if not ffmpeg_bin:
raise RuntimeError("ffmpeg not found — install ffmpeg in the render-worker container")
t0 = time.monotonic()
# 1. GLB conversion (OCC) — skipped when usd_path is provided
use_usd = bool(usd_path and usd_path.exists())
t_glb = time.monotonic()
if use_usd:
logger.info("[render_blender] cinematic using USD path: %s", usd_path)
else:
linear_deflection, angular_deflection, effective_engine = resolve_tessellation_settings(
tessellation_profile,
tessellation_engine,
)
glb_path = build_tessellated_glb_path(
step_path,
tessellation_profile,
effective_engine,
linear_deflection,
angular_deflection,
)
if not glb_path.exists() or glb_path.stat().st_size == 0:
_glb_from_step(
step_path,
glb_path,
tessellation_engine=effective_engine,
tessellation_profile=tessellation_profile,
)
else:
logger.info(
"GLB local hit: %s (%d KB) profile=%s linear=%s angular=%s engine=%s",
glb_path.name,
glb_path.stat().st_size // 1024,
tessellation_profile,
linear_deflection,
angular_deflection,
effective_engine,
)
glb_duration_s = round(time.monotonic() - t_glb, 2)
# 2. Render frames with Blender
frames_dir = output_path.parent / f"_frames_{output_path.stem}"
if frames_dir.exists():
_shutil.rmtree(frames_dir, ignore_errors=True)
ensure_group_writable_dir(frames_dir)
ensure_group_writable_dir(output_path.parent)
env = dict(os.environ)
env["EGL_PLATFORM"] = "surfaceless"
env["PYTHONUNBUFFERED"] = "1" # Force unbuffered stdout for live frame progress
glb_arg = "" if use_usd else str(glb_path)
cmd = [
blender_bin,
"--background",
"--python", str(script_path),
"--",
glb_arg, # [0] glb_path
str(frames_dir), # [1] frames_dir
str(frame_count), # [2] frame_count (480)
"0", # [3] degrees (unused, compat)
str(width), str(height), # [4] width, [5] height
engine, str(samples), # [6] engine, [7] samples
json.dumps(part_colors or {}), # [8] part_colors
template_path or "", # [9] template_path
target_collection, # [10] target_collection
material_library_path or "", # [11] material_library
json.dumps(material_map) if material_map else "{}", # [12] material_map
json.dumps(part_names_ordered) if part_names_ordered else "[]", # [13] part_names
"1" if lighting_only else "0", # [14] lighting_only
cycles_device, # [15] cycles_device
"1" if shadow_catcher else "0", # [16] shadow_catcher
str(rotation_x), str(rotation_y), str(rotation_z), # [17-19] rotation
"world_z", # [20] turntable_axis (unused)
"", # [21] bg_color (unused)
"1" if transparent_bg else "0", # [22] transparent_bg
]
if use_usd:
cmd += ["--usd-path", str(usd_path)]
if focal_length_mm is not None:
cmd += ["--focal-length", str(focal_length_mm)]
if sensor_width_mm is not None:
cmd += ["--sensor-width", str(sensor_width_mm)]
if material_override:
cmd += ["--material-override", material_override]
if template_inputs:
cmd += ["--template-inputs", json.dumps(template_inputs)]
log_lines: list[str] = []
t_render = time.monotonic()
import selectors as _sel
proc = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, env=env, start_new_session=True,
)
stderr_lines: list[str] = []
deadline = time.monotonic() + 7200 # 2hr max
sel = _sel.DefaultSelector()
sel.register(proc.stdout, _sel.EVENT_READ, "stdout")
sel.register(proc.stderr, _sel.EVENT_READ, "stderr")
try:
while sel.get_map():
remaining = deadline - time.monotonic()
if remaining <= 0:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
except (ProcessLookupError, OSError):
pass
break
events = sel.select(timeout=min(remaining, 2.0))
for key, _ in events:
line = key.fileobj.readline()
if not line:
sel.unregister(key.fileobj)
continue
line = line.rstrip("\n")
if key.data == "stdout":
logger.info("[cinematic] %s", line)
if "[cinematic_render]" in line or "Saved:" in line:
log_lines.append(line)
if log_callback:
# For Blender "Saved:" lines, format as frame progress
if "Saved:" in line and "frame_" in line:
import re as _re
m = _re.search(r'frame_(\d+)', line)
if m:
fnum = int(m.group(1))
log_callback(f"[cinematic_render] Frame {fnum}/480 rendered")
else:
log_callback(line)
else:
stderr_lines.append(line)
logger.warning("[cinematic stderr] %s", line)
finally:
sel.close()
proc.wait()
if proc.returncode != 0:
raise RuntimeError(
f"cinematic_render.py exited with code {proc.returncode}.\n"
f"stdout: {(stdout or '')[-2000:]}\n"
f"stderr: {(stderr or '')[-500:]}"
)
render_duration_s = round(time.monotonic() - t_render, 2)
# Check frames were written
frame_files = sorted(frames_dir.glob("frame_*.png"))
if not frame_files:
raise RuntimeError(f"No frames rendered in {frames_dir}")
logger.info("Cinematic rendered %d frames in %.1fs", len(frame_files), render_duration_s)
# 3. Compose frames -> mp4 with ffmpeg
t_ffmpeg = time.monotonic()
ffmpeg_cmd = [
ffmpeg_bin,
"-y",
"-framerate", str(fps),
"-i", str(frames_dir / "frame_%04d.png"),
"-vcodec", "libx264",
"-pix_fmt", "yuv420p",
"-crf", "18",
"-movflags", "+faststart",
str(output_path),
]
ffmpeg_proc = subprocess.run(
ffmpeg_cmd, capture_output=True, text=True, timeout=300
)
ffmpeg_duration_s = round(time.monotonic() - t_ffmpeg, 2)
for line in (ffmpeg_proc.stdout or "").splitlines():
logger.info("[ffmpeg] %s", line)
for line in (ffmpeg_proc.stderr or "").splitlines():
logger.debug("[ffmpeg stderr] %s", line)
if ffmpeg_proc.returncode != 0:
raise RuntimeError(
f"ffmpeg exited with code {ffmpeg_proc.returncode}.\n"
f"stderr: {(ffmpeg_proc.stderr or '')[-1000:]}"
)
# Clean up frames directory
try:
_shutil.rmtree(frames_dir)
except Exception:
pass
return {
"total_duration_s": round(time.monotonic() - t0, 2),
"stl_duration_s": glb_duration_s,
"render_duration_s": render_duration_s,
"ffmpeg_duration_s": ffmpeg_duration_s,
"stl_size_bytes": 0,
"output_size_bytes": output_path.stat().st_size if output_path.exists() else 0,
"frame_count": len(frame_files),
"engine_used": engine,
"log_lines": log_lines,
}