1008 lines
35 KiB
Python
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,
|
|
}
|