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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user