"""Order-line render tasks. Covers: - dispatch_order_line_render — thin router that queues render_order_line_task - render_order_line_task — full still/turntable render pipeline for one order line """ import logging from app.tasks.celery_app import celery_app from app.core.task_logs import log_task_event from app.core.pipeline_logger import PipelineLogger logger = logging.getLogger(__name__) @celery_app.task(name="app.tasks.step_tasks.dispatch_order_line_render", queue="step_processing") def dispatch_order_line_render(order_line_id: str): """Route an order-line render to render_order_line_task.""" logger.info(f"Dispatching render for order line: {order_line_id}") render_order_line_task.delay(order_line_id) @celery_app.task(bind=True, name="app.tasks.step_tasks.render_order_line_task", queue="thumbnail_rendering", max_retries=3) def render_order_line_task(self, order_line_id: str): """Render a specific output type for an order line. Loads OrderLine → Product → CadFile → OutputType.render_settings. Merges with system render settings. Stores result at order_line.result_path. """ pl = PipelineLogger(task_id=self.request.id, order_line_id=order_line_id) pl.step_start("render_order_line_task", {"order_line_id": order_line_id}) logger.info(f"Rendering order line: {order_line_id}") from app.services.render_log import emit emit(order_line_id, "Celery render task started") try: from sqlalchemy import create_engine, select, update as sql_update from sqlalchemy.orm import Session, joinedload from app.config import settings as app_settings # Use sync session for Celery (no async event loop) sync_url = app_settings.database_url.replace("+asyncpg", "") engine = create_engine(sync_url) with Session(engine) as session: from app.models.order_line import OrderLine from app.models.product import Product emit(order_line_id, "Loading order line from database") line = session.execute( select(OrderLine) .where(OrderLine.id == order_line_id) .options( joinedload(OrderLine.product).joinedload(Product.cad_file), joinedload(OrderLine.output_type), ) ).scalar_one_or_none() if line is None: emit(order_line_id, "Order line not found in database", "error") logger.error(f"OrderLine {order_line_id} not found") return if line.product.cad_file_id is None: emit(order_line_id, "Product has no CAD file — marking as failed", "error") logger.warning(f"OrderLine {order_line_id}: product has no CAD file") session.execute( sql_update(OrderLine) .where(OrderLine.id == line.id) .values(render_status="failed") ) session.commit() return # Mark as processing with timing from datetime import datetime render_start = datetime.utcnow() session.execute( sql_update(OrderLine) .where(OrderLine.id == line.id) .values( render_status="processing", render_backend_used="celery", render_started_at=render_start, ) ) session.commit() cad_file = line.product.cad_file materials_source = line.product.cad_part_materials part_colors = {} if cad_file and cad_file.parsed_objects: parsed_names = cad_file.parsed_objects.get("objects", []) if materials_source: from app.services.step_processor import build_part_colors part_colors = build_part_colors(parsed_names, materials_source) # Resolve render template + material library from app.services.template_service import resolve_template, get_material_library_path category_key = line.product.category_key if line.product else None ot_id = str(line.output_type_id) if line.output_type_id else None template = resolve_template(category_key=category_key, output_type_id=ot_id) material_library = get_material_library_path() # Build material_map (part_name → material_name) for material replacement. # Works with or without a render template — only suppressed if a # template explicitly has material_replace_enabled=False. material_map = None use_materials = bool(material_library and materials_source) if template and not template.material_replace_enabled: use_materials = False if use_materials: material_map = { m["part_name"]: m["material"] for m in materials_source if m.get("part_name") and m.get("material") } # Resolve raw material names to SCHAEFFLER library names via aliases from app.services.material_service import resolve_material_map material_map = resolve_material_map(material_map) if template: emit(order_line_id, f"Using render template: {template.name} (collection={template.target_collection}, material_replace={template.material_replace_enabled}, lighting_only={template.lighting_only})") logger.info(f"Render template resolved: '{template.name}' path={template.blend_file_path}, lighting_only={template.lighting_only}") else: emit(order_line_id, "No render template found — using factory settings (Mode A)") logger.info(f"No render template for category_key={category_key!r}, output_type_id={ot_id!r}") cad_name = cad_file.original_name if cad_file else "?" # Load render_position for rotation values (per-product takes priority, falls back to global) rotation_x = rotation_y = rotation_z = 0.0 if line.render_position_id: from app.models.render_position import ProductRenderPosition rp = session.get(ProductRenderPosition, line.render_position_id) if rp: rotation_x, rotation_y, rotation_z = rp.rotation_x, rp.rotation_y, rp.rotation_z emit(order_line_id, f"Render position: '{rp.name}' ({rotation_x}°, {rotation_y}°, {rotation_z}°)") elif line.global_render_position_id: from app.models import GlobalRenderPosition grp = session.get(GlobalRenderPosition, line.global_render_position_id) if grp: rotation_x, rotation_y, rotation_z = grp.rotation_x, grp.rotation_y, grp.rotation_z emit(order_line_id, f"Global render position: '{grp.name}' ({rotation_x}°, {rotation_y}°, {rotation_z}°)") emit(order_line_id, f"Starting render for {cad_name} ({len(part_colors)} coloured parts)") # Determine if this is an animation output type is_animation = bool(line.output_type and getattr(line.output_type, 'is_animation', False)) # Determine output format/extension out_ext = "jpg" if line.output_type and line.output_type.output_format: fmt = line.output_type.output_format.lower() if fmt == "mp4": out_ext = "mp4" elif fmt in ("png", "jpg", "jpeg"): out_ext = "png" if fmt == "png" else "jpg" # Build meaningful output filename import re def _sanitize(s: str) -> str: return re.sub(r'[^\w\-.]', '_', s.strip())[:100] product_name = line.product.name or line.product.pim_id or "product" ot_name = line.output_type.name if line.output_type else "render" filename = f"{_sanitize(product_name)}_{_sanitize(ot_name)}.{out_ext}" # Render to per-line output directory from pathlib import Path as _Path render_dir = _Path(app_settings.upload_dir) / "renders" / order_line_id render_dir.mkdir(parents=True, exist_ok=True) output_path = str(render_dir / filename) # Extract per-output-type render settings render_width = None render_height = None render_engine = None render_samples = None noise_threshold = "" denoiser = "" denoising_input_passes = "" denoising_prefilter = "" denoising_quality = "" denoising_use_gpu = "" frame_count = 24 fps = 25 bg_color = "" turntable_axis = "world_z" if line.output_type and line.output_type.render_settings: rs = line.output_type.render_settings if rs.get("width"): render_width = int(rs["width"]) if rs.get("height"): render_height = int(rs["height"]) if rs.get("engine"): render_engine = rs["engine"] if rs.get("samples"): render_samples = int(rs["samples"]) if rs.get("frame_count"): frame_count = int(rs["frame_count"]) if rs.get("fps"): fps = int(rs["fps"]) bg_color = rs.get("bg_color", "") turntable_axis = rs.get("turntable_axis", "world_z") noise_threshold = str(rs.get("noise_threshold", "")) denoiser = str(rs.get("denoiser", "")) denoising_input_passes = str(rs.get("denoising_input_passes", "")) denoising_prefilter = str(rs.get("denoising_prefilter", "")) denoising_quality = str(rs.get("denoising_quality", "")) denoising_use_gpu = str(rs.get("denoising_use_gpu", "")) transparent_bg = bool(line.output_type and line.output_type.transparent_bg) cycles_device_val = (line.output_type.cycles_device or "auto") if line.output_type else "auto" # Build ordered part names list for index-based Blender matching part_names_ordered = None if cad_file and cad_file.parsed_objects: part_names_ordered = cad_file.parsed_objects.get("objects", []) or None tmpl_info = f" template={template.name}" if template else "" if is_animation: # ── Turntable animation path ──────────────────────────────── emit(order_line_id, f"Starting turntable render: {frame_count} frames @ {fps}fps, {render_width or 1920}x{render_height or 1920}{tmpl_info}") pl.step_start("blender_turntable", {"frame_count": frame_count, "fps": fps}) from app.services.render_blender import is_blender_available, render_turntable_to_file if not is_blender_available(): raise RuntimeError("Blender not available on this worker") from app.services.step_processor import _get_all_settings _sys = _get_all_settings() try: service_data = render_turntable_to_file( step_path=_Path(cad_file.stored_path), output_path=_Path(output_path), frame_count=frame_count, fps=fps, width=render_width or 1920, height=render_height or 1920, engine=render_engine or _sys.get("blender_engine", "cycles"), samples=render_samples or int(_sys.get(f"blender_{render_engine or _sys.get('blender_engine','cycles')}_samples", 128)), stl_quality=_sys.get("stl_quality", "low"), smooth_angle=int(_sys.get("blender_smooth_angle", 30)), cycles_device=cycles_device_val, transparent_bg=transparent_bg, bg_color=bg_color, turntable_axis=turntable_axis, part_colors=part_colors or None, template_path=template.blend_file_path if template else None, target_collection=template.target_collection if template else "Product", material_library_path=material_library if use_materials else None, material_map=material_map, part_names_ordered=part_names_ordered, lighting_only=bool(template.lighting_only) if template else False, shadow_catcher=bool(template.shadow_catcher_enabled) if template else False, rotation_x=rotation_x, rotation_y=rotation_y, rotation_z=rotation_z, ) success = True render_log = { "renderer": "blender", "type": "turntable", "format": "mp4", "engine": render_engine or _sys.get("blender_engine", "cycles"), "engine_used": service_data.get("engine_used", "cycles"), "samples": render_samples, "cycles_device": cycles_device_val, "width": render_width or 1920, "height": render_height or 1920, "frame_count": service_data.get("frame_count", frame_count), "fps": fps, "total_duration_s": service_data.get("total_duration_s"), "stl_duration_s": service_data.get("stl_duration_s"), "render_duration_s": service_data.get("render_duration_s"), "ffmpeg_duration_s": service_data.get("ffmpeg_duration_s"), "stl_size_bytes": service_data.get("stl_size_bytes"), "output_size_bytes": service_data.get("output_size_bytes"), "log_lines": service_data.get("log_lines", []), } if template: render_log["template"] = template.blend_file_path pl.step_done("blender_turntable") except Exception as exc: success = False render_log = {"renderer": "blender", "type": "turntable", "error": str(exc)[:500]} pl.step_error("blender_turntable", str(exc), exc) logger.error("Turntable render failed for %s: %s", order_line_id, exc) else: # ── Still image path ──────────────────────────────────────── emit(order_line_id, f"Calling renderer (STEP → GLB → Blender) {render_width or 'default'}x{render_height or 'default'}{' [transparent]' if transparent_bg else ''}{f' engine={render_engine}' if render_engine else ''}{f' samples={render_samples}' if render_samples else ''}{tmpl_info}") pl.step_start("blender_still", {"width": render_width, "height": render_height}) from app.services.step_processor import render_to_file success, render_log = render_to_file( step_path=cad_file.stored_path, output_path=output_path, part_colors=part_colors, width=render_width, height=render_height, transparent_bg=transparent_bg, engine=render_engine, samples=render_samples, template_path=template.blend_file_path if template else None, target_collection=template.target_collection if template else "Product", material_library_path=material_library if use_materials else None, material_map=material_map, part_names_ordered=part_names_ordered, lighting_only=bool(template.lighting_only) if template else False, shadow_catcher=bool(template.shadow_catcher_enabled) if template else False, cycles_device=line.output_type.cycles_device if line.output_type else None, rotation_x=rotation_x, rotation_y=rotation_y, rotation_z=rotation_z, job_id=order_line_id, order_line_id=order_line_id, 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, ) if success: pl.step_done("blender_still") else: pl.step_error("blender_still", "render_to_file returned False") new_status = "completed" if success else "failed" render_end = datetime.utcnow() elapsed = (render_end - render_start).total_seconds() update_values = dict( render_status=new_status, render_completed_at=render_end, render_log=render_log, ) if success: update_values["result_path"] = output_path session.execute( sql_update(OrderLine) .where(OrderLine.id == line.id) .values(**update_values) ) session.commit() if success: # Create MediaAsset so the render appears in the Media Browser try: import os as _os 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") # 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 # Normalize storage_key to relative path _raw_key = str(output_path) _upload_prefix = str(_cfg2.upload_dir).rstrip("/") + "/" _norm_key = _raw_key[len(_upload_prefix):] if _raw_key.startswith(_upload_prefix) else _raw_key _existing = session.execute( select(MediaAsset.id).where(MediaAsset.storage_key == _norm_key).limit(1) ).scalar_one_or_none() if not _existing: # Probe output file for metadata _file_size = None _width = None _height = None if _os.path.exists(output_path): try: _file_size = _os.path.getsize(output_path) except OSError: pass if _ext in ("png", "jpg", "jpeg"): try: from PIL import Image as _PILImage with _PILImage.open(output_path) as _im: _width, _height = _im.size except Exception: pass # Snapshot key render settings into render_config _render_config = None if isinstance(render_log, dict): _render_config = { k: render_log[k] for k in ( "renderer", "engine_used", "engine", "samples", "device_used", "compute_type", "total_duration_s", ) if k in render_log } _asset = MediaAsset( tenant_id=_tenant_id, order_line_id=line.id, product_id=line.product_id, asset_type=_at, storage_key=_norm_key, mime_type=_mime, file_size_bytes=_file_size, width=_width, height=_height, render_config=_render_config, ) session.add(_asset) session.commit() except Exception: logger.exception("Failed to create MediaAsset for order_line %s", order_line_id) if success: emit(order_line_id, f"Render completed in {elapsed:.1f}s", "success") else: emit(order_line_id, f"Render failed after {elapsed:.1f}s", "error") # Broadcast WebSocket event for live UI updates try: from app.core.websocket import publish_event_sync _tenant_id = str(line.product.cad_file.tenant_id) if ( line.product and line.product.cad_file and line.product.cad_file.tenant_id ) else None if _tenant_id: publish_event_sync(_tenant_id, { "type": "render_complete" if success else "render_failed", "order_line_id": order_line_id, "order_id": str(line.order_id), "status": new_status, }) except Exception: logger.debug("WebSocket publish skipped (non-fatal)") # Emit per-render activity event (channel=activity, not shown in bell dropdown) try: from app.models.order import Order as OrderModel order_row = session.execute( select(OrderModel.created_by, OrderModel.order_number) .where(OrderModel.id == line.order_id) ).one_or_none() if order_row: from app.services.notification_service import emit_notification_sync, CHANNEL_ACTIVITY details: dict = { "order_number": order_row[1], "product_name": product_name, "output_type": ot_name, } if not success and isinstance(render_log, dict): err = render_log.get("error") or render_log.get("stderr", "") if err: details["error"] = str(err)[:300] emit_notification_sync( actor_user_id=None, target_user_id=str(order_row[0]), action="render.completed" if success else "render.failed", entity_type="order", entity_id=str(line.order_id), details=details, channel=CHANNEL_ACTIVITY, ) except Exception: logger.exception("Failed to emit render activity event") # Check if all lines for this order are done → auto-advance order_id_str = str(line.order_id) engine.dispose() from app.services.order_status_service import check_order_completion check_order_completion(order_id_str) pl.step_done("render_order_line_task") except Exception as exc: logger.error(f"render_order_line_task failed for {order_line_id}: {exc}") # If retries exhausted, mark as failed so the line doesn't stay stuck if self.request.retries >= self.max_retries: logger.error(f"Max retries reached for {order_line_id}, marking as failed") try: from sqlalchemy import create_engine, update as sql_update2 from sqlalchemy.orm import Session as SyncSession from app.config import settings as app_settings from app.models.order_line import OrderLine as OL2 sync_url2 = app_settings.database_url.replace("+asyncpg", "") eng2 = create_engine(sync_url2) with SyncSession(eng2) as s2: from datetime import datetime as dt2 s2.execute( sql_update2(OL2).where(OL2.id == order_line_id) .values( render_status="failed", render_completed_at=dt2.utcnow(), render_log={"error": str(exc)[:500]}, ) ) s2.commit() eng2.dispose() from app.services.order_status_service import check_order_completion # Try to get order_id from DB eng3 = create_engine(sync_url2) with SyncSession(eng3) as s3: from sqlalchemy import select as sel row = s3.execute(sel(OL2.order_id).where(OL2.id == order_line_id)).scalar_one_or_none() if row: check_order_completion(str(row)) eng3.dispose() # Notify the order creator about the failure try: from sqlalchemy import select as sel2 from app.models.order import Order as OrderModel2 eng4 = create_engine(sync_url2) with SyncSession(eng4) as s4: order_row2 = s4.execute( sel2(OrderModel2.created_by, OrderModel2.order_number) .join(OL2, OL2.order_id == OrderModel2.id) .where(OL2.id == order_line_id) ).one_or_none() eng4.dispose() if order_row2: from app.services.notification_service import emit_notification_sync, CHANNEL_ACTIVITY emit_notification_sync( actor_user_id=None, target_user_id=str(order_row2[0]), action="render.failed", entity_type="order", entity_id=None, details={ "order_number": order_row2[1], "product_name": "unknown", "output_type": "unknown", "error": str(exc)[:300], }, channel=CHANNEL_ACTIVITY, ) except Exception: logger.exception("Failed to emit render failure activity event") except Exception: logger.exception(f"Failed to mark {order_line_id} as failed in DB") raise raise self.retry(exc=exc, countdown=60)