diff --git a/backend/app/domains/pipeline/tasks/render_order_line.py b/backend/app/domains/pipeline/tasks/render_order_line.py
index 4e0a0c8..d7e644a 100644
--- a/backend/app/domains/pipeline/tasks/render_order_line.py
+++ b/backend/app/domains/pipeline/tasks/render_order_line.py
@@ -165,6 +165,11 @@ def render_order_line_task(self, order_line_id: str):
_usd_candidate.name, cad_file.id,
)
+ if usd_render_path:
+ emit(order_line_id, "Using USD master for render (skipping GLB tessellation)")
+ else:
+ emit(order_line_id, "No USD master available — using GLB tessellation path")
+
part_colors = {}
if cad_file and cad_file.parsed_objects:
parsed_names = cad_file.parsed_objects.get("objects", [])
@@ -312,6 +317,16 @@ def render_order_line_task(self, order_line_id: str):
denoising_quality = str(rs.get("denoising_quality", ""))
denoising_use_gpu = str(rs.get("denoising_use_gpu", ""))
+ # Auto-scale samples for lower resolutions (thumbnails/previews).
+ # Only applies when the output type provides both samples and dimensions.
+ if render_samples and render_width and render_height:
+ max_dim = max(render_width, render_height)
+ if max_dim <= 1024:
+ scaled = max(32, int(render_samples * max_dim / 2048))
+ if scaled < render_samples:
+ emit(order_line_id, f"Auto-scaled samples {render_samples} \u2192 {scaled} for {render_width}x{render_height}")
+ render_samples = scaled
+
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"
@@ -395,7 +410,8 @@ def render_order_line_task(self, order_line_id: str):
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}")
+ _render_path_label = "USD → Blender" if usd_render_path else "STEP → GLB → Blender"
+ emit(order_line_id, f"Calling renderer ({_render_path_label}) {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
diff --git a/backend/app/domains/pipeline/tasks/render_thumbnail.py b/backend/app/domains/pipeline/tasks/render_thumbnail.py
index d7b70b6..a5ac633 100644
--- a/backend/app/domains/pipeline/tasks/render_thumbnail.py
+++ b/backend/app/domains/pipeline/tasks/render_thumbnail.py
@@ -14,6 +14,40 @@ from app.core.pipeline_logger import PipelineLogger
logger = logging.getLogger(__name__)
+# Maximum samples for thumbnail renders (512x512).
+# Full-resolution renders use 256+ samples; thumbnails don't need more than 64.
+_THUMBNAIL_SAMPLE_CAP = 64
+
+
+@contextmanager
+def _capped_thumbnail_samples():
+ """Temporarily cap render samples for thumbnail renders.
+
+ Thumbnails are 512x512 — using 256 Cycles samples is wasteful.
+ This patches _get_all_settings in step_processor to cap samples
+ at _THUMBNAIL_SAMPLE_CAP for the duration of the thumbnail render.
+ """
+ import app.services.step_processor as _sp
+ _original = _sp._get_all_settings
+
+ def _patched() -> dict[str, str]:
+ settings = _original()
+ for key in ("blender_cycles_samples", "blender_eevee_samples"):
+ try:
+ val = int(settings.get(key, "256"))
+ if val > _THUMBNAIL_SAMPLE_CAP:
+ logger.info("Capping thumbnail %s: %d -> %d", key, val, _THUMBNAIL_SAMPLE_CAP)
+ settings[key] = str(_THUMBNAIL_SAMPLE_CAP)
+ except (ValueError, TypeError):
+ pass
+ return settings
+
+ _sp._get_all_settings = _patched
+ try:
+ yield
+ finally:
+ _sp._get_all_settings = _original
+
@contextmanager
def _pipeline_session(tenant_id: str | None = None):
@@ -67,11 +101,12 @@ def render_step_thumbnail(self, cad_file_id: str):
except Exception:
logger.warning(f"step_file_hash computation failed for {cad_file_id} (non-fatal)")
- # ── Render thumbnail ──────────────────────────────────────────────────
+ # ── Render thumbnail (with capped samples for 512x512) ──────────────
try:
from app.services.step_processor import regenerate_cad_thumbnail
pl.info("render_step_thumbnail", "Calling regenerate_cad_thumbnail")
- success = regenerate_cad_thumbnail(cad_file_id, part_colors={})
+ with _capped_thumbnail_samples():
+ success = regenerate_cad_thumbnail(cad_file_id, part_colors={})
if not success:
raise RuntimeError("regenerate_cad_thumbnail returned False")
except Exception as exc:
@@ -166,7 +201,8 @@ def regenerate_thumbnail(self, cad_file_id: str, part_colors: dict):
try:
from app.services.step_processor import regenerate_cad_thumbnail
- success = regenerate_cad_thumbnail(cad_file_id, part_colors)
+ with _capped_thumbnail_samples():
+ success = regenerate_cad_thumbnail(cad_file_id, part_colors)
if not success:
raise RuntimeError("regenerate_cad_thumbnail returned False")
except Exception as exc:
diff --git a/plan.md b/plan.md
index aa8ed04..93f2d47 100644
--- a/plan.md
+++ b/plan.md
@@ -1,123 +1,89 @@
-# Plan: Full UI/UX Cleanup & Simplification
+# Plan: Render Pipeline Performance Optimizations
## Context
-The OutputTypeTable was refactored from cramped 18-column inline editing to an expandable form row — a dramatic UX improvement. The same pattern should be applied to all other admin tables that suffer from the same problem. Additionally, several pages have UX inconsistencies (mixed editing patterns, tiny inputs, confusing controls) that need cleanup.
+Analysis of render logs shows the first render of a complex 140-part bearing takes 181s, while subsequent renders take 20s (OptiX cache — already fixed). Further optimizations can reduce per-render time and increase throughput.
-**Principle**: Tables are for **viewing** data. Editing happens in **expandable rows** or **modals** — never in cramped inline cells.
+Current baseline (2048x2048, 256 samples, Cycles GPU, OIDN denoiser):
+- GLB import: 7-11s
+- GPU render: 11-13s (warm cache)
+- Total: 20-22s per render
-## Affected Files
+## Tasks (in order of impact)
-| File | Change | Priority |
-|------|--------|----------|
-| `frontend/src/components/admin/RenderTemplateTable.tsx` | Expandable edit row (11 cramped columns) | HIGH |
-| `frontend/src/components/admin/PricingTierTable.tsx` | Expandable edit row (6 columns, mixed add/edit) | HIGH |
-| `frontend/src/components/admin/GlobalRenderPositionsPanel.tsx` | Expandable edit row (8 columns, tiny inputs) | MEDIUM |
-| `frontend/src/pages/WorkerManagement.tsx` | Larger touch targets, better scale controls | MEDIUM |
-| `frontend/src/pages/Billing.tsx` | Fix status dropdown disguised as badge | MEDIUM |
-| `frontend/src/pages/OrderDetail.tsx` | Cleaner line table, material override UX | LOW |
+### [x] Task 1: Resolution-aware sample count for thumbnails
-## Tasks (in order)
-
-### [x] Task 1: RenderTemplateTable — expandable edit row
-
-- **File**: `frontend/src/components/admin/RenderTemplateTable.tsx`
-- **What**: Same pattern as OutputTypeTable refactor:
- 1. Display row ALWAYS shows (no conditional switching)
- 2. Edit form opens as a new `
` below with `colSpan` spanning all columns
- 3. Grid form inside with labeled fields, grouped logically:
- - Row 1: Name, Category, Output Types (multi-select)
- - Row 2: Collection name, Material Replace, Lighting Only, Shadow Catcher, Camera Orbit
- - Row 3: .blend File (upload/download/re-upload with proper spacing)
- - Row 4: Active toggle + Save/Cancel buttons
- 4. "Add new" form also uses the expandable pattern (button at top opens a full-width form row)
- 5. Use `React.Fragment` for dual-row rendering
-- **Acceptance gate**: Edit mode shows a well-organized form below the display row; .blend file upload has proper spacing; no horizontal overflow
+- **File**: `backend/app/domains/pipeline/tasks/render_order_line.py`
+- **What**: When the output type resolution is <= 1024x1024 (thumbnails, previews), auto-scale samples down. Formula: `samples = max(32, base_samples * min(width, height) / 2048)`. Only apply when the output type doesn't explicitly set samples.
+- **Also**: `backend/app/domains/pipeline/tasks/render_thumbnail.py` — thumbnail renders use hardcoded settings; ensure they use low samples (32-64).
+- **Acceptance gate**: A 512x512 thumbnail uses ~64 samples instead of 256; a 2048x2048 HQ render still uses 256.
- **Dependencies**: None
-- **Risk**: The .blend file upload flow (upload vs clone) is complex — needs careful preservation
+- **Risk**: Low — only affects auto-calculated samples, explicit per-OT samples override this
+- **Savings**: 50-75% GPU time on thumbnail/preview renders
-### [x] Task 2: PricingTierTable — expandable edit row
+### [ ] Task 2: Prefer USD path over GLB when USD master exists
-- **File**: `frontend/src/components/admin/PricingTierTable.tsx`
-- **What**: Replace inline cell editing with expandable form row:
- 1. Display row always visible with read-only values
- 2. Edit form as expandable row below with grid layout:
- - Row 1: Category Key (select), Quality Level (input), Price per Item (number input)
- - Row 2: Description (textarea, full width)
- - Row 3: Active toggle + Save/Cancel
- 3. "Add Tier" button opens the same expandable form at the top
- 4. Consistent visual: accent left border on edit row
-- **Acceptance gate**: No inline cell editing; form has proper labels; description gets full width
+- **File**: `backend/app/domains/pipeline/tasks/render_order_line.py`
+- **What**: The render task already checks for USD masters (lines 145-166) but the GLB tessellation step still runs as fallback. Audit the USD detection logic and ensure:
+ 1. When `usd_render_path` is found, skip GLB tessellation entirely (no `export_step_to_gltf` subprocess)
+ 2. Log when USD path is used vs GLB fallback
+ 3. The USD path should be the default when available
+- **Also check**: `backend/app/services/render_blender.py` — verify `render_still()` skips GLB conversion when `usd_path` is provided (line 100-101 says it does)
+- **Acceptance gate**: A product with a USD master renders without the 7-11s GLB tessellation step
- **Dependencies**: None
-- **Risk**: Low — simple table with few fields
+- **Risk**: Low — USD path already works; this just ensures it's always preferred
-### [x] Task 3: GlobalRenderPositionsPanel — expandable edit row
+### [ ] Task 3: Enable Blender persistent data for animations
-- **File**: `frontend/src/components/admin/GlobalRenderPositionsPanel.tsx`
-- **What**: Replace inline cell editing with expandable form row:
- 1. Display row keeps compact view (Name, X°, Y°, Z°, Focal, Default, Order)
- 2. Edit form as expandable row:
- - Row 1: Name (wide), Is Default (checkbox with label)
- - Row 2: Rotation X°, Rotation Y°, Rotation Z° (number inputs with proper width)
- - Row 3: Focal Length mm, Sensor Width mm, Sort Order
- - Row 4: Save/Cancel buttons
- 3. "Add Position" opens same expandable form
- 4. Wider number inputs (`w-24` instead of `w-16`) for comfortable editing
-- **Acceptance gate**: Rotation inputs are comfortable to edit; no cramped cells; proper labels
+- **File**: `render-worker/scripts/turntable_render.py`
+- **What**: Add `scene.render.use_persistent_data = True` before rendering turntable frames. This keeps the BVH acceleration structure in memory between frames, avoiding rebuild for each of the 12-24 frames.
+- **Acceptance gate**: Turntable renders of complex products are 20-30% faster
- **Dependencies**: None
-- **Risk**: Low — straightforward table
+- **Risk**: Low — Blender 5.0 supports this; increases VRAM usage slightly
-### [x] Task 4: WorkerManagement — better scale controls
+### [ ] Task 4: Dual render queue for light/heavy workloads
-- **File**: `frontend/src/pages/WorkerManagement.tsx`
-- **What**: Improve the concurrency/scale controls:
- 1. Replace tiny `w-6` number displays with `w-12` minimum
- 2. Make up/down buttons larger (`p-2` instead of default, `rounded-lg`)
- 3. Add proper labels above each control ("Min Concurrency", "Max Concurrency")
- 4. Group the scale controls in a card with clear section header
- 5. Make the "Save" button more prominent (full-width at bottom of card)
-- **Acceptance gate**: Controls are easy to click; labels are clear; no overflow on mobile
+- **Files**:
+ - `docker-compose.yml` — add second render-worker service for light tasks
+ - `backend/app/domains/pipeline/tasks/render_thumbnail.py` — route thumbnails to light queue
+ - `backend/app/domains/pipeline/tasks/render_order_line.py` — route based on resolution
+- **What**: Split `asset_pipeline` into two queues:
+ - `asset_pipeline` — heavy renders (2048x2048, turntables): concurrency=1
+ - `asset_pipeline_light` — thumbnails and small stills (<=1024): concurrency=2
+ - Route based on output resolution or task type
+- **Acceptance gate**: Thumbnail generation doesn't block HQ renders; 2 thumbnails render concurrently
+- **Dependencies**: Task 1 (lower samples for light queue makes concurrent rendering safer)
+- **Risk**: Medium — VRAM contention if both workers render simultaneously. Mitigated by thumbnails being small (512x512, 64 samples = minimal VRAM)
+
+### [ ] Task 5: Skip re-tessellation when GLB already exists
+
+- **File**: `backend/app/services/render_blender.py`
+- **What**: In `render_still()`, the STEP→GLB tessellation runs every time. Cache the GLB file per CAD file (already stored as `gltf_geometry` MediaAsset). Before tessellating, check if a GLB MediaAsset exists for this cad_file_id and reuse it.
+- **Also**: `backend/app/domains/pipeline/tasks/render_order_line.py` — pass the existing GLB path to the render service when available
+- **Acceptance gate**: Second render of same product skips the 7-11s tessellation step; GLB is reused from MediaAsset
+- **Dependencies**: Task 2 (USD path is preferred; this is fallback for products without USD)
+- **Risk**: Low — GLB is deterministic per CAD file; if the CAD file changes, a new GLB is generated
+
+### [ ] Task 6: Output format optimization (WebP for stills)
+
+- **File**: `render-worker/scripts/_blender_scene_setup.py` (or `blender_render.py`)
+- **What**: After Blender renders a PNG, optionally convert to WebP for 50-70% smaller files. Add a `webp` output format option to OutputType. When selected, render as PNG then convert via Pillow.
+- **Also**: `backend/app/services/render_blender.py` — add post-render WebP conversion
+- **Acceptance gate**: WebP output type produces smaller files with no visible quality loss
- **Dependencies**: None
-- **Risk**: Low — cosmetic changes only
-
-### [x] Task 5: Billing — fix status dropdown UX
-
-- **File**: `frontend/src/pages/Billing.tsx`
-- **What**: Fix the status dropdown that looks like a badge:
- 1. Replace the `