refactor(A2): replace blender-renderer HTTP service with render-worker Celery container
- Create render-worker/ with Dockerfile (Ubuntu + cadquery + Blender via host mount) - Add render-worker/check_version.py: verifies Blender >= 5.0.1 at startup, Exit 1 on failure - Add render-worker/scripts/: blender_render.py, still_render.py, turntable_render.py - Create backend/app/services/render_blender.py: direct subprocess rendering - convert_step_to_stl() and export_per_part_stls() using cadquery - render_still(): STEP → STL → PNG via Blender subprocess - is_blender_available(): detects BLENDER_BIN env for render-worker context - Create backend/app/domains/rendering/tasks.py: render_still_task + render_turntable_task - Update step_processor.py: use subprocess path when BLENDER_BIN env is set (render-worker) - Update step_tasks.py: generate_stl_cache uses direct cadquery instead of HTTP - Remove blender-renderer and threejs-renderer from docker-compose.yml - Replace worker-thumbnail with render-worker (Ubuntu + cadquery + Blender mount) - Remove Docker SDK from backend Dockerfile (was only for flamenco scaling) - Update .env.example: BLENDER_VERSION=5.0.1 documented - Update celery_app.py: include domains.rendering.tasks in autodiscover Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -359,24 +359,20 @@ async def generate_missing_stls(
|
||||
async def renderer_status(
|
||||
admin: User = Depends(require_admin),
|
||||
):
|
||||
"""Check health of external renderer services."""
|
||||
import httpx
|
||||
services = {
|
||||
"pillow": {"url": None, "available": True, "note": "Built-in (always available)"},
|
||||
"blender": {"url": "http://blender-renderer:8100/health", "available": False, "note": ""},
|
||||
"""Check health of renderer services."""
|
||||
from app.services.render_blender import find_blender, is_blender_available
|
||||
blender_available = is_blender_available()
|
||||
blender_bin = find_blender()
|
||||
return {
|
||||
"pillow": {"available": True, "note": "Built-in (always available)"},
|
||||
"blender": {
|
||||
"available": blender_available,
|
||||
"note": (
|
||||
f"render-worker subprocess ({blender_bin})"
|
||||
if blender_available
|
||||
else "Blender not found — check render-worker container and BLENDER_BIN"
|
||||
),
|
||||
},
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||
for name, info in services.items():
|
||||
if info["url"] is None:
|
||||
continue
|
||||
try:
|
||||
resp = await client.get(info["url"])
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
services[name]["available"] = True
|
||||
services[name]["note"] = data.get("renderer", name)
|
||||
except Exception as e:
|
||||
services[name]["note"] = str(e)[:100]
|
||||
return services
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
"""Rendering domain tasks — Celery tasks for Blender-based rendering.
|
||||
|
||||
These tasks run on the `thumbnail_rendering` queue in the render-worker
|
||||
container, which has Blender and cadquery available.
|
||||
|
||||
Phase A2: Initial implementation replacing the blender-renderer HTTP service.
|
||||
Phase B: This module will be expanded as part of the Domain-Driven restructure.
|
||||
"""
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from app.tasks.celery_app import celery_app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
bind=True,
|
||||
name="app.domains.rendering.tasks.render_still_task",
|
||||
queue="thumbnail_rendering",
|
||||
max_retries=2,
|
||||
)
|
||||
def render_still_task(
|
||||
self,
|
||||
step_path: str,
|
||||
output_path: str,
|
||||
engine: str = "cycles",
|
||||
samples: int = 256,
|
||||
stl_quality: str = "low",
|
||||
smooth_angle: int = 30,
|
||||
cycles_device: str = "auto",
|
||||
width: int = 512,
|
||||
height: int = 512,
|
||||
transparent_bg: bool = False,
|
||||
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 = "",
|
||||
) -> dict:
|
||||
"""Render a STEP file to a still PNG via Blender subprocess.
|
||||
|
||||
Returns render metadata dict on success.
|
||||
Retries up to 2 times on failure (30s countdown).
|
||||
"""
|
||||
try:
|
||||
from app.services.render_blender import render_still
|
||||
result = render_still(
|
||||
step_path=Path(step_path),
|
||||
output_path=Path(output_path),
|
||||
engine=engine,
|
||||
samples=samples,
|
||||
stl_quality=stl_quality,
|
||||
smooth_angle=smooth_angle,
|
||||
cycles_device=cycles_device,
|
||||
width=width,
|
||||
height=height,
|
||||
transparent_bg=transparent_bg,
|
||||
template_path=template_path,
|
||||
target_collection=target_collection,
|
||||
material_library_path=material_library_path,
|
||||
material_map=material_map,
|
||||
part_names_ordered=part_names_ordered,
|
||||
lighting_only=lighting_only,
|
||||
shadow_catcher=shadow_catcher,
|
||||
rotation_x=rotation_x,
|
||||
rotation_y=rotation_y,
|
||||
rotation_z=rotation_z,
|
||||
noise_threshold=noise_threshold,
|
||||
denoiser=denoiser,
|
||||
denoising_input_passes=denoising_input_passes,
|
||||
denoising_prefilter=denoising_prefilter,
|
||||
denoising_quality=denoising_quality,
|
||||
denoising_use_gpu=denoising_use_gpu,
|
||||
)
|
||||
logger.info(
|
||||
"render_still_task completed: %s → %s in %.1fs",
|
||||
Path(step_path).name, Path(output_path).name,
|
||||
result.get("total_duration_s", 0),
|
||||
)
|
||||
return result
|
||||
except Exception as exc:
|
||||
logger.error("render_still_task failed for %s: %s", step_path, exc)
|
||||
raise self.retry(exc=exc, countdown=30)
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
bind=True,
|
||||
name="app.domains.rendering.tasks.render_turntable_task",
|
||||
queue="thumbnail_rendering",
|
||||
max_retries=2,
|
||||
)
|
||||
def render_turntable_task(
|
||||
self,
|
||||
step_path: str,
|
||||
output_dir: str,
|
||||
output_name: str = "turntable",
|
||||
engine: str = "cycles",
|
||||
samples: int = 64,
|
||||
stl_quality: str = "low",
|
||||
smooth_angle: int = 30,
|
||||
cycles_device: str = "auto",
|
||||
width: int = 1920,
|
||||
height: int = 1080,
|
||||
frame_count: int = 120,
|
||||
fps: int = 30,
|
||||
turntable_degrees: float = 360.0,
|
||||
turntable_axis: str = "world_z",
|
||||
bg_color: str = "",
|
||||
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,
|
||||
camera_orbit: bool = True,
|
||||
rotation_x: float = 0.0,
|
||||
rotation_y: float = 0.0,
|
||||
rotation_z: float = 0.0,
|
||||
) -> dict:
|
||||
"""Render a STEP file as a turntable animation (frames + FFmpeg composite).
|
||||
|
||||
Returns render metadata dict on success.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from app.services.render_blender import (
|
||||
find_blender, convert_step_to_stl, export_per_part_stls
|
||||
)
|
||||
|
||||
blender_bin = find_blender()
|
||||
if not blender_bin:
|
||||
raise RuntimeError("Blender binary not found in render-worker container")
|
||||
|
||||
step = Path(step_path)
|
||||
out_dir = Path(output_dir)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
scripts_dir = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
|
||||
turntable_script = scripts_dir / "turntable_render.py"
|
||||
|
||||
# STL conversion
|
||||
stl_path = step.parent / f"{step.stem}_{stl_quality}.stl"
|
||||
if not stl_path.exists() or stl_path.stat().st_size == 0:
|
||||
convert_step_to_stl(step, stl_path, stl_quality)
|
||||
parts_dir = step.parent / f"{step.stem}_{stl_quality}_parts"
|
||||
if not (parts_dir / "manifest.json").exists():
|
||||
try:
|
||||
export_per_part_stls(step, parts_dir, stl_quality)
|
||||
except Exception as exc:
|
||||
logger.warning("per-part export non-fatal: %s", exc)
|
||||
|
||||
# Build turntable render arguments
|
||||
frames_dir = out_dir / "frames"
|
||||
frames_dir.mkdir(exist_ok=True)
|
||||
|
||||
cmd = [
|
||||
blender_bin, "--background",
|
||||
"--python", str(turntable_script),
|
||||
"--",
|
||||
str(stl_path),
|
||||
str(frames_dir),
|
||||
output_name,
|
||||
str(width), str(height),
|
||||
engine, str(samples), str(smooth_angle), cycles_device,
|
||||
str(frame_count), str(fps), str(turntable_degrees), turntable_axis,
|
||||
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",
|
||||
"1" if camera_orbit else "0",
|
||||
str(rotation_x), str(rotation_y), str(rotation_z),
|
||||
]
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=3600
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"Blender turntable exited {result.returncode}:\n{result.stdout[-2000:]}"
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("render_turntable_task failed: %s", exc)
|
||||
raise self.retry(exc=exc, countdown=60)
|
||||
|
||||
# FFmpeg composite: frames → MP4 with optional background
|
||||
output_mp4 = out_dir / f"{output_name}.mp4"
|
||||
ffmpeg_cmd = _build_ffmpeg_cmd(
|
||||
frames_dir, output_mp4, fps=fps, bg_color=bg_color
|
||||
)
|
||||
try:
|
||||
subprocess.run(ffmpeg_cmd, check=True, capture_output=True, text=True, timeout=300)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise RuntimeError(f"FFmpeg composite failed: {exc.stderr[-500:]}")
|
||||
|
||||
return {
|
||||
"output_mp4": str(output_mp4),
|
||||
"frame_count": frame_count,
|
||||
"fps": fps,
|
||||
}
|
||||
|
||||
|
||||
def _build_ffmpeg_cmd(
|
||||
frames_dir: Path, output_mp4: Path, fps: int = 30, bg_color: str = ""
|
||||
) -> list:
|
||||
"""Build FFmpeg command for compositing turntable frames to MP4."""
|
||||
import shutil as _shutil
|
||||
ffmpeg = _shutil.which("ffmpeg") or "ffmpeg"
|
||||
frame_pattern = str(frames_dir / "%04d.png")
|
||||
|
||||
if bg_color:
|
||||
# Overlay transparent frames onto solid color background
|
||||
r = int(bg_color[1:3], 16) if bg_color.startswith("#") else 255
|
||||
g = int(bg_color[3:5], 16) if bg_color.startswith("#") else 255
|
||||
b = int(bg_color[5:7], 16) if bg_color.startswith("#") else 255
|
||||
color_str = f"color=c=0x{r:02x}{g:02x}{b:02x}:s=1920x1080:r={fps}"
|
||||
return [
|
||||
ffmpeg, "-y",
|
||||
"-f", "lavfi", "-i", color_str,
|
||||
"-framerate", str(fps), "-i", frame_pattern,
|
||||
"-filter_complex", "[0:v][1:v]overlay=0:0",
|
||||
"-c:v", "libx264", "-pix_fmt", "yuv420p",
|
||||
"-movflags", "+faststart",
|
||||
str(output_mp4),
|
||||
]
|
||||
else:
|
||||
return [
|
||||
ffmpeg, "-y",
|
||||
"-framerate", str(fps), "-i", frame_pattern,
|
||||
"-c:v", "libx264", "-pix_fmt", "yuv420p",
|
||||
"-movflags", "+faststart",
|
||||
str(output_mp4),
|
||||
]
|
||||
@@ -0,0 +1,330 @@
|
||||
"""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 json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MIN_BLENDER_VERSION = (5, 0, 1)
|
||||
|
||||
|
||||
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 convert_step_to_stl(step_path: Path, stl_path: Path, quality: str = "low") -> None:
|
||||
"""Convert a STEP file to STL using cadquery.
|
||||
|
||||
Raises ImportError if cadquery is not installed (not available in backend
|
||||
container — only in render-worker container).
|
||||
"""
|
||||
import cadquery as cq # only available in render-worker
|
||||
|
||||
if quality == "high":
|
||||
shape = cq.importers.importStep(str(step_path))
|
||||
cq.exporters.export(shape, str(stl_path), tolerance=0.01, angularTolerance=0.02)
|
||||
else:
|
||||
shape = cq.importers.importStep(str(step_path))
|
||||
cq.exporters.export(shape, str(stl_path), tolerance=0.3, angularTolerance=0.3)
|
||||
|
||||
if not stl_path.exists() or stl_path.stat().st_size == 0:
|
||||
raise RuntimeError("cadquery produced empty STL")
|
||||
|
||||
|
||||
def export_per_part_stls(step_path: Path, parts_dir: Path, quality: str = "low") -> list:
|
||||
"""Export one STL per named STEP leaf shape using OCP XCAF.
|
||||
|
||||
Returns the manifest list (may be empty on failure — non-fatal).
|
||||
"""
|
||||
tol = 0.01 if quality == "high" else 0.3
|
||||
angular_tol = 0.05 if quality == "high" else 0.3
|
||||
|
||||
try:
|
||||
from OCP.STEPCAFControl import STEPCAFControl_Reader
|
||||
from OCP.XCAFDoc import XCAFDoc_DocumentTool, XCAFDoc_ShapeTool
|
||||
from OCP.TDataStd import TDataStd_Name
|
||||
from OCP.TDF import TDF_Label as TDF_Label_cls, TDF_LabelSequence
|
||||
from OCP.XCAFApp import XCAFApp_Application
|
||||
from OCP.TDocStd import TDocStd_Document
|
||||
from OCP.TCollection import TCollection_ExtendedString
|
||||
from OCP.IFSelect import IFSelect_RetDone
|
||||
import cadquery as cq
|
||||
except ImportError as e:
|
||||
logger.warning("per-part export skipped (import error): %s", e)
|
||||
return []
|
||||
|
||||
app = XCAFApp_Application.GetApplication_s()
|
||||
doc = TDocStd_Document(TCollection_ExtendedString("XmlOcaf"))
|
||||
app.InitDocument(doc)
|
||||
|
||||
reader = STEPCAFControl_Reader()
|
||||
reader.SetNameMode(True)
|
||||
status = reader.ReadFile(str(step_path))
|
||||
if status != IFSelect_RetDone:
|
||||
logger.warning("XCAF reader failed with status %s", status)
|
||||
return []
|
||||
|
||||
if not reader.Transfer(doc):
|
||||
logger.warning("XCAF transfer failed")
|
||||
return []
|
||||
|
||||
shape_tool = XCAFDoc_DocumentTool.ShapeTool_s(doc.Main())
|
||||
name_id = TDataStd_Name.GetID_s()
|
||||
|
||||
leaves = []
|
||||
|
||||
def _get_label_name(label):
|
||||
name_attr = TDataStd_Name()
|
||||
if label.FindAttribute(name_id, name_attr):
|
||||
return name_attr.Get().ToExtString()
|
||||
return ""
|
||||
|
||||
def _collect_leaves(label):
|
||||
if XCAFDoc_ShapeTool.IsAssembly_s(label):
|
||||
components = TDF_LabelSequence()
|
||||
XCAFDoc_ShapeTool.GetComponents_s(label, components)
|
||||
for i in range(1, components.Length() + 1):
|
||||
comp_label = components.Value(i)
|
||||
if XCAFDoc_ShapeTool.IsReference_s(comp_label):
|
||||
ref_label = TDF_Label_cls()
|
||||
XCAFDoc_ShapeTool.GetReferredShape_s(comp_label, ref_label)
|
||||
comp_name = _get_label_name(comp_label)
|
||||
ref_name = _get_label_name(ref_label)
|
||||
name = ref_name or comp_name
|
||||
if XCAFDoc_ShapeTool.IsAssembly_s(ref_label):
|
||||
_collect_leaves(ref_label)
|
||||
elif XCAFDoc_ShapeTool.IsSimpleShape_s(ref_label):
|
||||
shape = XCAFDoc_ShapeTool.GetShape_s(comp_label)
|
||||
leaves.append((name or f"unnamed_{len(leaves)}", shape))
|
||||
else:
|
||||
_collect_leaves(comp_label)
|
||||
elif XCAFDoc_ShapeTool.IsSimpleShape_s(label):
|
||||
name = _get_label_name(label)
|
||||
shape = XCAFDoc_ShapeTool.GetShape_s(label)
|
||||
leaves.append((name or f"unnamed_{len(leaves)}", shape))
|
||||
|
||||
top_labels = TDF_LabelSequence()
|
||||
shape_tool.GetFreeShapes(top_labels)
|
||||
for i in range(1, top_labels.Length() + 1):
|
||||
_collect_leaves(top_labels.Value(i))
|
||||
|
||||
if not leaves:
|
||||
logger.warning("no leaf shapes found via XCAF")
|
||||
return []
|
||||
|
||||
parts_dir.mkdir(parents=True, exist_ok=True)
|
||||
manifest = []
|
||||
|
||||
for idx, (name, shape) in enumerate(leaves):
|
||||
safe_name = name.replace("/", "_").replace("\\", "_").replace(" ", "_")
|
||||
filename = f"{idx:02d}_{safe_name}.stl"
|
||||
filepath = str(parts_dir / filename)
|
||||
try:
|
||||
cq_shape = cq.Shape(shape)
|
||||
cq_shape.exportStl(filepath, tolerance=tol, angularTolerance=angular_tol)
|
||||
manifest.append({"index": idx, "name": name, "file": filename})
|
||||
except Exception as e:
|
||||
logger.warning("failed to export part '%s': %s", name, e)
|
||||
|
||||
manifest_path = parts_dir / "manifest.json"
|
||||
with open(manifest_path, "w") as f:
|
||||
json.dump({"parts": manifest}, f, indent=2)
|
||||
|
||||
return manifest
|
||||
|
||||
|
||||
def render_still(
|
||||
step_path: Path,
|
||||
output_path: Path,
|
||||
width: int = 512,
|
||||
height: int = 512,
|
||||
engine: str = "cycles",
|
||||
samples: int = 256,
|
||||
stl_quality: str = "low",
|
||||
smooth_angle: int = 30,
|
||||
cycles_device: str = "auto",
|
||||
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 = "",
|
||||
) -> dict:
|
||||
"""Convert STEP → STL (cadquery) → PNG (Blender subprocess).
|
||||
|
||||
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():
|
||||
# Fallback: look next to this file (development mode)
|
||||
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()
|
||||
|
||||
# 1. STL conversion (cadquery)
|
||||
stl_path = step_path.parent / f"{step_path.stem}_{stl_quality}.stl"
|
||||
parts_dir = step_path.parent / f"{step_path.stem}_{stl_quality}_parts"
|
||||
|
||||
t_stl = time.monotonic()
|
||||
if not stl_path.exists() or stl_path.stat().st_size == 0:
|
||||
logger.info("STL cache miss — converting: %s", step_path.name)
|
||||
convert_step_to_stl(step_path, stl_path, stl_quality)
|
||||
else:
|
||||
logger.info("STL cache hit: %s (%d KB)", stl_path.name, stl_path.stat().st_size // 1024)
|
||||
stl_size_bytes = stl_path.stat().st_size if stl_path.exists() else 0
|
||||
|
||||
if not (parts_dir / "manifest.json").exists():
|
||||
try:
|
||||
export_per_part_stls(step_path, parts_dir, stl_quality)
|
||||
except Exception as exc:
|
||||
logger.warning("per-part STL export failed (non-fatal): %s", exc)
|
||||
|
||||
stl_duration_s = round(time.monotonic() - t_stl, 2)
|
||||
|
||||
# 2. Blender render
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
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"
|
||||
|
||||
def _build_cmd(eng: str) -> list:
|
||||
return [
|
||||
blender_bin,
|
||||
"--background",
|
||||
"--python", str(script_path),
|
||||
"--",
|
||||
str(stl_path),
|
||||
str(output_path),
|
||||
str(width), str(height),
|
||||
eng, str(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),
|
||||
noise_threshold or "", denoiser or "",
|
||||
denoising_input_passes or "", denoising_prefilter or "",
|
||||
denoising_quality or "", denoising_use_gpu or "",
|
||||
]
|
||||
|
||||
def _run(eng: str) -> subprocess.CompletedProcess:
|
||||
proc = subprocess.Popen(
|
||||
_build_cmd(eng),
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
text=True, env=env, start_new_session=True,
|
||||
)
|
||||
try:
|
||||
stdout, stderr = proc.communicate(timeout=600)
|
||||
except subprocess.TimeoutExpired:
|
||||
try:
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
||||
except (ProcessLookupError, OSError):
|
||||
pass
|
||||
stdout, stderr = proc.communicate()
|
||||
return subprocess.CompletedProcess(_build_cmd(eng), proc.returncode, stdout, stderr)
|
||||
|
||||
t_render = time.monotonic()
|
||||
result = _run(engine)
|
||||
engine_used = engine
|
||||
|
||||
log_lines = []
|
||||
for line in (result.stdout or "").splitlines():
|
||||
logger.info("[blender] %s", line)
|
||||
if "[blender_render]" in line:
|
||||
log_lines.append(line)
|
||||
for line in (result.stderr or "").splitlines():
|
||||
logger.warning("[blender stderr] %s", line)
|
||||
|
||||
# EEVEE fallback to Cycles on non-signal error
|
||||
if result.returncode > 0 and engine == "eevee":
|
||||
logger.warning("EEVEE failed (exit %d) — retrying with Cycles", result.returncode)
|
||||
result = _run("cycles")
|
||||
engine_used = "cycles (eevee fallback)"
|
||||
for line in (result.stdout or "").splitlines():
|
||||
logger.info("[blender-fallback] %s", line)
|
||||
if "[blender_render]" in line:
|
||||
log_lines.append(line)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"Blender exited with code {result.returncode}.\n"
|
||||
f"stdout: {(result.stdout or '')[-2000:]}\n"
|
||||
f"stderr: {(result.stderr or '')[-500:]}"
|
||||
)
|
||||
|
||||
render_duration_s = round(time.monotonic() - t_render, 2)
|
||||
|
||||
parts_count = 0
|
||||
manifest_file = parts_dir / "manifest.json"
|
||||
if manifest_file.exists():
|
||||
try:
|
||||
data = json.loads(manifest_file.read_text())
|
||||
parts_count = len(data.get("parts", []))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"total_duration_s": round(time.monotonic() - t0, 2),
|
||||
"stl_duration_s": stl_duration_s,
|
||||
"render_duration_s": render_duration_s,
|
||||
"stl_size_bytes": stl_size_bytes,
|
||||
"output_size_bytes": output_path.stat().st_size if output_path.exists() else 0,
|
||||
"parts_count": parts_count,
|
||||
"engine_used": engine_used,
|
||||
"log_lines": log_lines,
|
||||
}
|
||||
@@ -329,8 +329,9 @@ def _generate_thumbnail(
|
||||
"height": 512,
|
||||
})
|
||||
elif renderer == "threejs":
|
||||
size = int(settings["threejs_render_size"])
|
||||
render_log.update({"width": size, "height": size})
|
||||
# Three.js renderer removed in v2; treat as pillow fallback
|
||||
renderer = "pillow"
|
||||
render_log.update({"renderer": "pillow", "threejs_removed": True})
|
||||
|
||||
logger.info(f"Thumbnail renderer={renderer}, format={fmt}")
|
||||
|
||||
@@ -340,29 +341,25 @@ def _generate_thumbnail(
|
||||
if renderer == "blender":
|
||||
engine = settings["blender_engine"]
|
||||
samples = int(settings[f"blender_{engine}_samples"])
|
||||
extra = {
|
||||
"engine": engine,
|
||||
"samples": samples,
|
||||
"stl_quality": settings["stl_quality"],
|
||||
"smooth_angle": int(settings["blender_smooth_angle"]),
|
||||
"cycles_device": settings["cycles_device"],
|
||||
}
|
||||
rendered_png, service_data = _render_via_service(
|
||||
"http://blender-renderer:8100/render", step_path, tmp_png, extra
|
||||
)
|
||||
if not rendered_png:
|
||||
logger.warning("Blender renderer failed; falling back to Pillow placeholder")
|
||||
|
||||
elif renderer == "threejs":
|
||||
size = int(settings["threejs_render_size"])
|
||||
extra2: dict = {"width": size, "height": size}
|
||||
if part_colors is not None:
|
||||
extra2["part_colors"] = part_colors
|
||||
rendered_png, service_data = _render_via_service(
|
||||
"http://threejs-renderer:8101/render", step_path, tmp_png, extra2
|
||||
)
|
||||
if not rendered_png:
|
||||
logger.warning("Three.js renderer failed; falling back to Pillow placeholder")
|
||||
from app.services.render_blender import is_blender_available, render_still
|
||||
if is_blender_available():
|
||||
try:
|
||||
service_data = render_still(
|
||||
step_path=step_path,
|
||||
output_path=tmp_png,
|
||||
engine=engine,
|
||||
samples=samples,
|
||||
stl_quality=settings["stl_quality"],
|
||||
smooth_angle=int(settings["blender_smooth_angle"]),
|
||||
cycles_device=settings["cycles_device"],
|
||||
)
|
||||
rendered_png = tmp_png if tmp_png.exists() else None
|
||||
except Exception as exc:
|
||||
logger.warning("Blender subprocess render failed: %s", exc)
|
||||
rendered_png = None
|
||||
else:
|
||||
logger.warning("Blender not available in this container — falling back to Pillow placeholder")
|
||||
|
||||
# Merge rich service response data into render_log
|
||||
if service_data:
|
||||
@@ -669,20 +666,43 @@ def render_to_file(
|
||||
extra["denoising_quality"] = denoising_quality
|
||||
if denoising_use_gpu:
|
||||
extra["denoising_use_gpu"] = denoising_use_gpu
|
||||
rendered_png, service_data = _render_via_service(
|
||||
"http://blender-renderer:8100/render", step, tmp_png, extra, job_id=job_id
|
||||
)
|
||||
from app.services.render_blender import is_blender_available, render_still
|
||||
if is_blender_available():
|
||||
try:
|
||||
service_data = render_still(
|
||||
step_path=step,
|
||||
output_path=tmp_png,
|
||||
engine=actual_engine,
|
||||
samples=actual_samples,
|
||||
stl_quality=settings["stl_quality"],
|
||||
smooth_angle=int(settings["blender_smooth_angle"]),
|
||||
cycles_device=actual_cycles_device,
|
||||
width=w, height=h,
|
||||
transparent_bg=transparent_bg,
|
||||
part_colors=part_colors,
|
||||
template_path=template_path,
|
||||
target_collection=target_collection,
|
||||
material_library_path=material_library_path,
|
||||
material_map=material_map,
|
||||
part_names_ordered=part_names_ordered,
|
||||
lighting_only=lighting_only,
|
||||
shadow_catcher=shadow_catcher,
|
||||
rotation_x=rotation_x, rotation_y=rotation_y, rotation_z=rotation_z,
|
||||
noise_threshold=noise_threshold, denoiser=denoiser,
|
||||
denoising_input_passes=denoising_input_passes,
|
||||
denoising_prefilter=denoising_prefilter,
|
||||
denoising_quality=denoising_quality,
|
||||
denoising_use_gpu=denoising_use_gpu,
|
||||
)
|
||||
rendered_png = tmp_png if tmp_png.exists() else None
|
||||
except Exception as exc:
|
||||
logger.warning("Blender subprocess render failed: %s", exc)
|
||||
rendered_png = None
|
||||
else:
|
||||
logger.warning("Blender not available in this container — using Pillow fallback")
|
||||
elif renderer == "threejs":
|
||||
default_size = int(settings["threejs_render_size"])
|
||||
w = width or default_size
|
||||
h = height or default_size
|
||||
render_log.update({"width": w, "height": h})
|
||||
extra2: dict = {"width": w, "height": h}
|
||||
if part_colors is not None:
|
||||
extra2["part_colors"] = part_colors
|
||||
rendered_png, service_data = _render_via_service(
|
||||
"http://threejs-renderer:8101/render", step, tmp_png, extra2
|
||||
)
|
||||
# Three.js renderer removed in v2 — fall through to Pillow placeholder
|
||||
logger.warning("Three.js renderer removed; using Pillow fallback")
|
||||
|
||||
if service_data:
|
||||
for key in ("total_duration_s", "stl_duration_s", "render_duration_s",
|
||||
|
||||
@@ -5,7 +5,11 @@ celery_app = Celery(
|
||||
"schaefflerautomat",
|
||||
broker=settings.redis_url,
|
||||
backend=settings.redis_url,
|
||||
include=["app.tasks.step_tasks", "app.tasks.ai_tasks"],
|
||||
include=[
|
||||
"app.tasks.step_tasks",
|
||||
"app.tasks.ai_tasks",
|
||||
"app.domains.rendering.tasks",
|
||||
],
|
||||
)
|
||||
|
||||
celery_app.conf.update(
|
||||
@@ -17,6 +21,7 @@ celery_app.conf.update(
|
||||
task_routes={
|
||||
"app.tasks.step_tasks.*": {"queue": "step_processing"},
|
||||
"app.tasks.ai_tasks.*": {"queue": "ai_validation"},
|
||||
"app.domains.rendering.tasks.*": {"queue": "thumbnail_rendering"},
|
||||
},
|
||||
beat_schedule={},
|
||||
)
|
||||
|
||||
@@ -157,7 +157,6 @@ def generate_stl_cache(self, cad_file_id: str, quality: str):
|
||||
from sqlalchemy.orm import Session
|
||||
from app.config import settings as app_settings
|
||||
from app.models.cad_file import CadFile
|
||||
import httpx
|
||||
|
||||
logger.info(f"Generating {quality}-quality STL for CAD file: {cad_file_id}")
|
||||
|
||||
@@ -172,16 +171,20 @@ def generate_stl_cache(self, cad_file_id: str, quality: str):
|
||||
eng.dispose()
|
||||
|
||||
try:
|
||||
resp = httpx.post(
|
||||
"http://blender-renderer:8100/convert-stl",
|
||||
json={"step_path": step_path, "quality": quality},
|
||||
timeout=600.0,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
logger.info(f"STL cached: {data['stl_path']} ({data['size_bytes']} bytes) in {data['duration_s']}s")
|
||||
else:
|
||||
raise RuntimeError(f"blender-renderer returned {resp.status_code}: {resp.text[:300]}")
|
||||
from app.services.render_blender import convert_step_to_stl, export_per_part_stls
|
||||
from pathlib import Path as _Path
|
||||
step = _Path(step_path)
|
||||
stl_out = step.parent / f"{step.stem}_{quality}.stl"
|
||||
parts_dir = step.parent / f"{step.stem}_{quality}_parts"
|
||||
|
||||
if not stl_out.exists() or stl_out.stat().st_size == 0:
|
||||
convert_step_to_stl(step, stl_out, quality)
|
||||
if not (parts_dir / "manifest.json").exists():
|
||||
try:
|
||||
export_per_part_stls(step, parts_dir, quality)
|
||||
except Exception as pe:
|
||||
logger.warning(f"Per-part STL export non-fatal: {pe}")
|
||||
logger.info(f"STL cached: {stl_out} ({stl_out.stat().st_size // 1024} KB)")
|
||||
except Exception as exc:
|
||||
logger.error(f"STL generation failed for {cad_file_id} quality={quality}: {exc}")
|
||||
raise self.retry(exc=exc, countdown=30, max_retries=2)
|
||||
|
||||
Reference in New Issue
Block a user