perf: dual queue, GLB caching, WebP output, persistent BVH

Task 4: Dual render queue
- render-worker: heavy (asset_pipeline, concurrency=1) — HQ 2048x2048, animations
- render-worker-light: light (asset_pipeline_light, concurrency=2) — thumbnails, <=1024
- Thumbnails routed to light queue automatically
- Order line renders routed by resolution at dispatch time

Task 5: GLB caching (skip re-tessellation)
- Before tessellating, check if gltf_geometry MediaAsset exists for the cad_file_id
- If found, copy to expected path — render_blender.py finds it and skips tessellation
- Saves 7-11s per re-render of the same product

Task 6: WebP output format
- New 'webp' option in output_format (OutputType admin)
- Blender renders PNG intermediate, Pillow converts to WebP (quality=90, method=4)
- 50-70% smaller files with no visible quality loss
- Correct MIME type (image/webp) in MediaAsset

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-15 12:07:12 +01:00
parent ffe3eebfca
commit 5a148554c0
5 changed files with 132 additions and 11 deletions
@@ -40,8 +40,29 @@ def dispatch_order_line_render(order_line_id: str):
logger.info(f"OrderLine {order_line_id}: order {order.status.value} — not dispatching")
return
logger.info(f"Dispatching render for order line: {order_line_id}")
render_order_line_task.delay(order_line_id)
# Route light renders (small stills) to asset_pipeline_light,
# heavy renders (HQ stills, animations) stay on asset_pipeline.
is_animation = False
max_dim = 0
if line:
from app.models.output_type import OutputType
ot = session.execute(
select(OutputType).where(OutputType.id == line.output_type_id)
).scalar_one_or_none() if line.output_type_id else None
if ot:
is_animation = bool(getattr(ot, 'is_animation', False))
rs = ot.render_settings or {}
w = int(rs.get("width", 0) or 0)
h = int(rs.get("height", 0) or 0)
max_dim = max(w, h)
if max_dim > 0 and max_dim <= 1024 and not is_animation:
target_queue = "asset_pipeline_light"
else:
target_queue = "asset_pipeline"
logger.info(f"Dispatching render for order line: {order_line_id} -> queue={target_queue}")
render_order_line_task.apply_async(args=[order_line_id], queue=target_queue)
@celery_app.task(bind=True, name="app.tasks.step_tasks.render_order_line_task", queue="asset_pipeline", max_retries=3)
@@ -165,10 +186,48 @@ def render_order_line_task(self, order_line_id: str):
_usd_candidate.name, cad_file.id,
)
# Look up existing GLB geometry asset — reuse to skip re-tessellation
# when rendering via the GLB path (non-USD fallback).
glb_reuse_path = None
if cad_file and not usd_render_path:
_glb_asset = session.execute(
select(MediaAsset)
.where(
MediaAsset.cad_file_id == cad_file.id,
MediaAsset.asset_type == MediaAssetType.gltf_geometry,
)
.order_by(MediaAsset.created_at.desc())
.limit(1)
).scalar_one_or_none()
if _glb_asset and _glb_asset.storage_key:
_glb_candidate = _Path(app_settings.upload_dir) / _glb_asset.storage_key
if _glb_candidate.exists() and _glb_candidate.stat().st_size > 0:
# Copy to the path render_blender.py expects so its
# local cache check (`glb_path.exists()`) finds it.
_step_path = _Path(cad_file.stored_path)
_expected_glb = _step_path.parent / f"{_step_path.stem}_thumbnail.glb"
if not _expected_glb.exists() or _expected_glb.stat().st_size == 0:
try:
import shutil as _shutil
_shutil.copy2(str(_glb_candidate), str(_expected_glb))
logger.info(
"render_order_line: reused gltf_geometry asset %s -> %s",
_glb_candidate.name, _expected_glb.name,
)
glb_reuse_path = _expected_glb
except Exception as _copy_exc:
logger.warning(
"render_order_line: failed to copy GLB asset: %s", _copy_exc,
)
else:
glb_reuse_path = _expected_glb
if usd_render_path:
emit(order_line_id, "Using USD master for render (skipping GLB tessellation)")
elif glb_reuse_path:
emit(order_line_id, f"Reusing cached GLB geometry ({glb_reuse_path.name}) — skipping re-tessellation")
else:
emit(order_line_id, "No USD master available — using GLB tessellation path")
emit(order_line_id, "No USD master or cached GLB — will tessellate STEP -> GLB")
part_colors = {}
if cad_file and cad_file.parsed_objects:
@@ -261,6 +320,8 @@ def render_order_line_task(self, order_line_id: str):
fmt = line.output_type.output_format.lower()
if fmt == "mp4":
out_ext = "mp4"
elif fmt == "webp":
out_ext = "webp"
elif fmt in ("png", "jpg", "jpeg"):
out_ext = "png" if fmt == "png" else "jpg"
@@ -479,7 +540,12 @@ def render_order_line_task(self, order_line_id: str):
from app.domains.media.models import MediaAsset, MediaAssetType as MAT
from app.config import settings as _cfg2
_ext = str(output_path).rsplit(".", 1)[-1].lower() if "." in str(output_path) else "bin"
_mime = "video/mp4" if _ext in ("mp4", "webm") else ("image/jpeg" if _ext in ("jpg", "jpeg") else "image/png")
_mime = (
"video/mp4" if _ext in ("mp4", "webm")
else "image/webp" if _ext == "webp"
else "image/jpeg" if _ext in ("jpg", "jpeg")
else "image/png"
)
# Extension determines type — poster frames (.jpg/.png) from animations are still stills
_at = MAT.turntable if _ext in ("mp4", "webm") else MAT.still
_tenant_id = line.product.cad_file.tenant_id if (line.product and line.product.cad_file) else None