"""Render dispatcher — routes render jobs to Celery or Flamenco. Backend selection priority: 1. OutputType.render_backend per-type override ("celery" / "flamenco") 2. OutputType.is_animation — animations default to Flamenco 3. System setting render_backend — global default ("celery" / "flamenco" / "auto") 4. "auto" mode: stills → Celery, animations → Flamenco """ import json import logging from datetime import datetime from sqlalchemy import select, update as sql_update from sqlalchemy.orm import Session, joinedload from app.models.order_line import OrderLine from app.models.output_type import OutputType from app.models.product import Product from app.models.system_setting import SystemSetting logger = logging.getLogger(__name__) def _load_setting(session: Session, key: str, default: str = "") -> str: """Load a single system setting (sync).""" row = session.execute( select(SystemSetting).where(SystemSetting.key == key) ).scalar_one_or_none() return row.value if row else default def resolve_backend(output_type: OutputType | None, system_backend: str) -> str: """Determine which backend to use for a given output type. Returns "celery" or "flamenco". """ if output_type is None: return "celery" # Priority 1: explicit per-type override ot_backend = output_type.render_backend if ot_backend in ("celery", "flamenco"): return ot_backend # Priority 2+3: is_animation + system setting if system_backend in ("celery", "flamenco"): return system_backend # Priority 4: auto mode — animations → Flamenco, stills → Celery if output_type.is_animation: return "flamenco" return "celery" def build_flamenco_job_settings( output_type: OutputType, product: Product, step_path: str, output_dir: str, system_settings: dict[str, str], lighting_only: bool = False, shadow_catcher: bool = False, camera_orbit: bool = True, cycles_device: str = "auto", rotation_x: float = 0.0, rotation_y: float = 0.0, rotation_z: float = 0.0, ) -> dict: """Build Flamenco job settings from output type and product metadata.""" render_settings = output_type.render_settings or {} engine = render_settings.get("engine", system_settings.get("blender_engine", "cycles")) samples_key = f"blender_{engine}_samples" samples = render_settings.get("samples", int(system_settings.get(samples_key, "256"))) stl_quality = render_settings.get("stl_quality", system_settings.get("stl_quality", "low")) width = render_settings.get("width", 1920 if output_type.is_animation else 1024) height = render_settings.get("height", 1080 if output_type.is_animation else 1024) part_colors = {} part_names_ordered = [] if product.cad_file and product.cad_file.parsed_objects: part_names_ordered = product.cad_file.parsed_objects.get("objects", []) materials_source = product.cad_part_materials if materials_source: from app.services.step_processor import build_part_colors part_colors = build_part_colors(part_names_ordered, materials_source) transparent_bg = bool(output_type.transparent_bg) if hasattr(output_type, 'transparent_bg') else False settings = { "step_path": step_path, "engine": engine, "samples": samples, "stl_quality": stl_quality, "width": width, "height": height, "part_colors_json": json.dumps(part_colors), "transparent_bg": transparent_bg, "template_path": "", "target_collection": "Product", "material_library_path": "", "material_map_json": "{}", "part_names_ordered_json": json.dumps(part_names_ordered), "lighting_only": lighting_only, "shadow_catcher": shadow_catcher, "cycles_device": cycles_device, "rotation_x": rotation_x, "rotation_y": rotation_y, "rotation_z": rotation_z, } for dk in ('noise_threshold', 'denoiser', 'denoising_input_passes', 'denoising_prefilter', 'denoising_quality', 'denoising_use_gpu'): settings[dk] = str(render_settings.get(dk, "")) if output_type.is_animation: # Turntable-specific settings output_name = render_settings.get("output_name", "turntable") settings["output_dir"] = output_dir settings["output_name"] = output_name settings["frame_count"] = render_settings.get("frame_count", 120) settings["fps"] = render_settings.get("fps", 30) settings["turntable_degrees"] = render_settings.get("turntable_degrees", 360) settings["turntable_axis"] = render_settings.get("turntable_axis", "world_z") settings["bg_color"] = render_settings.get("bg_color", "") settings["camera_orbit"] = camera_orbit else: # Still-specific settings ext = output_type.output_format or "png" settings["output_path"] = f"{output_dir}/render.{ext}" return settings def dispatch_render(order_line_id: str) -> dict: """Route a render job to Celery or Flamenco based on configuration. Must be called from a sync context (Celery task or sync wrapper). Returns {"backend": "celery"|"flamenco", "job_ref": str}. """ from app.config import settings as app_settings from app.services.render_log import emit, clear clear(order_line_id) emit(order_line_id, "Dispatch started — loading order line data") sync_url = app_settings.database_url.replace("+asyncpg", "") from sqlalchemy import create_engine engine_db = create_engine(sync_url) with Session(engine_db) as session: 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", "error") logger.error(f"OrderLine {order_line_id} not found") return {"backend": "none", "job_ref": "", "error": "not_found"} product_name = line.product.name or line.product.pim_id or "unknown" output_name = line.output_type.name if line.output_type else "default" emit(order_line_id, f"Product: {product_name} | Output: {output_name}") 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 {"backend": "none", "job_ref": "", "error": "no_cad_file"} cad_name = line.product.cad_file.original_name if line.product.cad_file else "?" emit(order_line_id, f"CAD file: {cad_name}") # Load system settings system_backend = _load_setting(session, "render_backend", "celery") flamenco_url = _load_setting(session, "flamenco_manager_url", "http://flamenco-manager:8080") backend = resolve_backend(line.output_type, system_backend) emit(order_line_id, f"Resolved backend: {backend}") # Mark as processing now = datetime.utcnow() session.execute( sql_update(OrderLine) .where(OrderLine.id == line.id) .values( render_status="processing", render_backend_used=backend, render_started_at=now, ) ) session.commit() if backend == "flamenco": emit(order_line_id, f"Submitting job to Flamenco Manager ({flamenco_url})") result = _dispatch_flamenco(session, line, flamenco_url) if result.get("error"): emit(order_line_id, f"Flamenco submit failed: {result['error']}", "error") else: emit(order_line_id, f"Flamenco job submitted: {result.get('job_ref', '?')}") return result else: emit(order_line_id, "Dispatching to Celery render worker") return _dispatch_celery(order_line_id) engine_db.dispose() def _dispatch_celery(order_line_id: str) -> dict: """Dispatch to the existing Celery render task.""" from app.tasks.step_tasks import render_order_line_task result = render_order_line_task.delay(order_line_id) return {"backend": "celery", "job_ref": result.id} def _dispatch_flamenco(session: Session, line: OrderLine, flamenco_url: str) -> dict: """Submit a job to Flamenco Manager.""" import re from app.services.flamenco_client import get_flamenco_client # Load all needed system settings all_keys = ["blender_engine", "blender_cycles_samples", "blender_eevee_samples", "stl_quality", "cycles_device"] sys_settings = {} for key in all_keys: sys_settings[key] = _load_setting(session, key, "") output_type = line.output_type product = line.product cad_file = product.cad_file # Load render_position for rotation values 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 # Flamenco mounts the uploads volume at /shared, backend uses /app/uploads raw_path = cad_file.stored_path if cad_file else "" step_path = raw_path.replace("/app/uploads/", "/shared/") if raw_path else "" output_dir = f"/shared/renders/{line.id}" job_type = "schaeffler-turntable" if (output_type and output_type.is_animation) else "schaeffler-still" # Resolve render template + material library BEFORE building job settings # (template.lighting_only is needed by build_flamenco_job_settings) from app.services.template_service import resolve_template, get_material_library_path category_key = product.category_key if 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() # Resolve cycles_device: per-output-type override wins, fall back to system setting ot_cycles_device = output_type.cycles_device if output_type else None effective_cycles_device = ot_cycles_device or sys_settings.get("cycles_device", "gpu") or "gpu" settings = build_flamenco_job_settings( output_type=output_type, product=product, step_path=step_path, output_dir=output_dir, system_settings=sys_settings, lighting_only=bool(template.lighting_only) if template else False, shadow_catcher=bool(template.shadow_catcher_enabled) if template else False, camera_orbit=bool(template.camera_orbit) if template else True, cycles_device=effective_cycles_device, rotation_x=rotation_x, rotation_y=rotation_y, rotation_z=rotation_z, ) if template: # Remap path for Flamenco shared volume tmpl_path = template.blend_file_path.replace("/app/uploads/", "/shared/") settings["template_path"] = tmpl_path settings["target_collection"] = template.target_collection logger.info( f"Flamenco job: using render template '{template.name}' " f"(id={template.id}, path={tmpl_path}, collection={template.target_collection})" ) else: logger.info( f"Flamenco job: no render template found for " f"category_key={category_key!r}, output_type_id={ot_id!r} — using factory settings" ) # Material library + material map: send whenever library exists and product # has material assignments — works with or without a render template. # When a template is present, only apply if material_replace_enabled is set. materials_source = product.cad_part_materials use_materials = bool(material_library and materials_source) if template and not template.material_replace_enabled: use_materials = False if use_materials: mat_lib_path = material_library.replace("/app/uploads/", "/shared/") settings["material_library_path"] = mat_lib_path mat_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 mat_map = resolve_material_map(mat_map) settings["material_map_json"] = json.dumps(mat_map) # Output naming: meaningful filename instead of generic render.ext def _sanitize(s: str) -> str: return re.sub(r'[^\w\-.]', '_', s.strip())[:100] product_name = product.name or product.pim_id or "product" ot_name = output_type.name if output_type else "render" if not (output_type and output_type.is_animation): ext = output_type.output_format or "png" if output_type else "png" filename = f"{_sanitize(product_name)}_{_sanitize(ot_name)}.{ext}" settings["output_path"] = f"{output_dir}/{filename}" metadata = { "order_line_id": str(line.id), "order_id": str(line.order_id), "product_name": product.name or "", "output_type": output_type.name if output_type else "", "category": product.category_key or "", } job_name = f"{product.name or product.pim_id} - {output_type.name if output_type else 'render'}" try: client = get_flamenco_client(flamenco_url) job = client.submit_job( name=job_name[:200], job_type=job_type, settings=settings, metadata=metadata, ) job_id = job.get("id", "") # Save flamenco_job_id session.execute( sql_update(OrderLine) .where(OrderLine.id == line.id) .values(flamenco_job_id=job_id) ) session.commit() logger.info(f"Flamenco job submitted: {job_id} for OrderLine {line.id}") return {"backend": "flamenco", "job_ref": job_id} except Exception as exc: logger.error(f"Flamenco submit failed for OrderLine {line.id}: {exc}") session.execute( sql_update(OrderLine) .where(OrderLine.id == line.id) .values( render_status="failed", render_completed_at=datetime.utcnow(), render_log={"error": f"Flamenco submit failed: {str(exc)[:500]}"}, ) ) session.commit() return {"backend": "flamenco", "job_ref": "", "error": str(exc)}