f1e02ded78
Previously the cache_service was only used in the generate_stl_cache Celery task. All render paths (render_still, render_turntable_to_file, render_turntable_task) only checked for a local file and converted from scratch if missing. Changes: - render_blender.py: add _stl_from_cache_or_convert() helper that checks MinIO cache before falling back to local STEP→STL conversion. Wire into render_still() and render_turntable_to_file() (both STL conversion blocks). - domains/rendering/tasks.py: wire MinIO cache check into render_turntable_task() inline before convert_step_to_stl(). All errors are non-fatal (falls back to fresh conversion). Now a STEP file converted on one worker is available to all workers via MinIO, avoiding redundant cadquery conversions on re-renders. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
303 lines
10 KiB
Python
303 lines
10 KiB
Python
"""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 = "",
|
|
mesh_attributes: dict | None = None,
|
|
) -> 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,
|
|
mesh_attributes=mesh_attributes or {},
|
|
)
|
|
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 — try MinIO cache first, then convert locally
|
|
stl_path = step.parent / f"{step.stem}_{stl_quality}.stl"
|
|
if not stl_path.exists() or stl_path.stat().st_size == 0:
|
|
try:
|
|
from app.domains.products.cache_service import compute_step_hash, check_stl_cache
|
|
step_hash = compute_step_hash(str(step))
|
|
cached = check_stl_cache(step_hash, stl_quality)
|
|
if cached:
|
|
stl_path.write_bytes(cached)
|
|
logger.info("STL restored from MinIO cache: %s", stl_path.name)
|
|
else:
|
|
convert_step_to_stl(step, stl_path, stl_quality)
|
|
except Exception as exc:
|
|
logger.warning("MinIO cache check failed (non-fatal): %s — falling back to conversion", exc)
|
|
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,
|
|
}
|
|
|
|
|
|
@celery_app.task(
|
|
name="rendering.publish_asset",
|
|
queue="step_processing",
|
|
)
|
|
def publish_asset(
|
|
order_line_id: str,
|
|
asset_type: str,
|
|
storage_key: str,
|
|
render_config: dict | None = None,
|
|
) -> str | None:
|
|
"""Create a MediaAsset record after a successful render."""
|
|
import asyncio
|
|
|
|
async def _run() -> str | None:
|
|
from app.database import AsyncSessionLocal
|
|
from app.domains.media.models import MediaAsset, MediaAssetType
|
|
from app.domains.orders.models import OrderLine
|
|
from sqlalchemy import select
|
|
|
|
async with AsyncSessionLocal() as db:
|
|
res = await db.execute(select(OrderLine).where(OrderLine.id == order_line_id))
|
|
line = res.scalar_one_or_none()
|
|
if not line:
|
|
return None
|
|
asset = MediaAsset(
|
|
tenant_id=getattr(line, "tenant_id", None),
|
|
order_line_id=line.id,
|
|
asset_type=MediaAssetType(asset_type),
|
|
storage_key=storage_key,
|
|
render_config=render_config,
|
|
)
|
|
db.add(asset)
|
|
await db.commit()
|
|
return str(asset.id)
|
|
|
|
return asyncio.get_event_loop().run_until_complete(_run())
|
|
|
|
|
|
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),
|
|
]
|