From b892f72f7e9c07f78147f0efee1a6da39b2e9827 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Sun, 15 Mar 2026 12:26:38 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20per-line=20render=20overrides=20?= =?UTF-8?q?=E2=80=94=20override=20any=20output=20type=20setting=20at=20ord?= =?UTF-8?q?er=20time?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of duplicating output types for every variation (WebP vs PNG, different resolution), keep one canonical output type and override specific fields per order line via render_overrides JSONB. Backend: - render_overrides JSONB column on OrderLine (DB migration) - Render task merges overrides with output type settings (format, width, height, samples, engine, denoiser, transparent_bg, cycles_device) - POST /orders/{id}/batch-render-overrides endpoint for bulk override - PatchLineBody accepts render_overrides for per-line patching Frontend: - Batch render overrides section on OrderDetail: output format dropdown (PNG/JPG/WebP) + resolution dropdown (512-4096) - Clear button to remove overrides MCP: - create_order tool: accepts product_ids, output_type, render_overrides, material_override — enables "render all products as WebP" via Claude - set_render_overrides tool: batch override on existing orders Co-Authored-By: Claude Opus 4.6 (1M context) --- LEARNINGS.md | 3 + ...5af_add_render_overrides_to_order_lines.py | 30 +++++++ backend/app/api/routers/orders.py | 30 +++++++ backend/app/domains/orders/models.py | 1 + backend/app/domains/orders/schemas.py | 2 + .../pipeline/tasks/render_order_line.py | 58 +++++++++++++ frontend/src/api/orders.ts | 12 ++- frontend/src/pages/OrderDetail.tsx | 60 ++++++++++++- schaeffler_mcp_server.py | 87 +++++++++++++++++++ 9 files changed, 281 insertions(+), 2 deletions(-) create mode 100644 backend/alembic/versions/f5906aaf75af_add_render_overrides_to_order_lines.py diff --git a/LEARNINGS.md b/LEARNINGS.md index 2c49c80..05fe3f7 100644 --- a/LEARNINGS.md +++ b/LEARNINGS.md @@ -7,6 +7,9 @@ ## Learnings +### 2026-03-15 | Architecture | Per-order-line render overrides via JSONB +Render overrides (JSONB) on OrderLine allow overriding any output type render setting (format, resolution, samples, engine, etc.) at order time without duplicating output types. Applied AFTER output type render_settings AND after transparent_bg/cycles_device_val assignment, so they take final priority. Also affects dispatch queue routing (width/height overrides change light vs heavy queue routing). + ### 2026-03-14 | Blender | OBJ-Rotation Turntable: Reparenting von USD-Parts verschiebt Geometrie USD-importierte Parts haben existierende Eltern-Objekte (Xform-Nodes). `part.parent = pivot; part.matrix_parent_inverse = pivot.matrix_world.inverted()` verliert den Beitrag des alten Parents zur Welt-Position → Parts verschieben sich ~14m. Fix: World-Matrix vor Reparenting sichern, dann via `part.matrix_local = pivot.inverted() @ saved_world` wiederherstellen. Zusätzlich `bpy.context.view_layer.update()` vor dem Parenting aufrufen, damit `pivot.matrix_world` aktuell ist. diff --git a/backend/alembic/versions/f5906aaf75af_add_render_overrides_to_order_lines.py b/backend/alembic/versions/f5906aaf75af_add_render_overrides_to_order_lines.py new file mode 100644 index 0000000..3f6fc36 --- /dev/null +++ b/backend/alembic/versions/f5906aaf75af_add_render_overrides_to_order_lines.py @@ -0,0 +1,30 @@ +"""add render_overrides to order_lines + +Revision ID: f5906aaf75af +Revises: 42b686e71c47 +Create Date: 2026-03-15 11:24:23.387869 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = 'f5906aaf75af' +down_revision: Union[str, None] = '42b686e71c47' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('order_lines', sa.Column('render_overrides', postgresql.JSONB(astext_type=sa.Text()), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('order_lines', 'render_overrides') + # ### end Alembic commands ### diff --git a/backend/app/api/routers/orders.py b/backend/app/api/routers/orders.py index 9f16ff7..dfd2a06 100644 --- a/backend/app/api/routers/orders.py +++ b/backend/app/api/routers/orders.py @@ -97,6 +97,7 @@ def _build_line_out(line: OrderLine) -> OrderLineOut: render_started_at=line.render_started_at if hasattr(line, 'render_started_at') else None, render_completed_at=line.render_completed_at if hasattr(line, 'render_completed_at') else None, material_override=getattr(line, 'material_override', None), + render_overrides=getattr(line, 'render_overrides', None), notes=line.notes, created_at=line.created_at, updated_at=line.updated_at, @@ -391,6 +392,7 @@ async def create_order( global_render_position_id=line_data.global_render_position_id, gewuenschte_bildnummer=line_data.gewuenschte_bildnummer, material_override=line_data.material_override, + render_overrides=line_data.render_overrides, notes=line_data.notes, tenant_id=getattr(user, 'tenant_id', None), ) @@ -836,6 +838,7 @@ async def add_order_line( global_render_position_id=body.global_render_position_id, gewuenschte_bildnummer=body.gewuenschte_bildnummer, material_override=body.material_override, + render_overrides=body.render_overrides, notes=body.notes, tenant_id=getattr(user, 'tenant_id', None), ) @@ -1125,8 +1128,35 @@ async def batch_material_override( return {"updated": res.rowcount, "material_override": body.material_override} +class BatchRenderOverridesBody(BaseModel): + render_overrides: dict | None = None + + +@router.post("/{order_id}/batch-render-overrides") +async def batch_render_overrides( + order_id: uuid.UUID, + body: BatchRenderOverridesBody, + user: User = Depends(require_admin_or_pm), + db: AsyncSession = Depends(get_db), +): + """Set render_overrides on ALL lines of an order at once.""" + result = await db.execute(select(Order).where(Order.id == order_id)) + if not result.scalar_one_or_none(): + raise HTTPException(404, detail="Order not found") + + from sqlalchemy import update as sql_update + res = await db.execute( + sql_update(OrderLine) + .where(OrderLine.order_id == order_id) + .values(render_overrides=body.render_overrides) + ) + await db.commit() + return {"updated": res.rowcount, "render_overrides": body.render_overrides} + + class PatchLineBody(BaseModel): material_override: str | None = None + render_overrides: dict | None = None @router.patch("/{order_id}/lines/{line_id}") diff --git a/backend/app/domains/orders/models.py b/backend/app/domains/orders/models.py index a82ad65..71b3f10 100644 --- a/backend/app/domains/orders/models.py +++ b/backend/app/domains/orders/models.py @@ -151,6 +151,7 @@ class OrderLine(Base): nullable=True, ) material_override: Mapped[str | None] = mapped_column(String(200), nullable=True, default=None) + render_overrides: Mapped[dict | None] = mapped_column(JSONB, nullable=True, default=None) notes: Mapped[str | None] = mapped_column(Text, nullable=True) tenant_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True, index=True diff --git a/backend/app/domains/orders/schemas.py b/backend/app/domains/orders/schemas.py index 23a905c..ac8ca68 100644 --- a/backend/app/domains/orders/schemas.py +++ b/backend/app/domains/orders/schemas.py @@ -67,6 +67,7 @@ class OrderLineCreate(BaseModel): global_render_position_id: uuid.UUID | None = None gewuenschte_bildnummer: str | None = None material_override: str | None = None + render_overrides: dict | None = None notes: str | None = None @@ -93,6 +94,7 @@ class OrderLineOut(BaseModel): render_started_at: datetime | None = None render_completed_at: datetime | None = None material_override: str | None = None + render_overrides: dict | None = None notes: str | None created_at: datetime updated_at: datetime diff --git a/backend/app/domains/pipeline/tasks/render_order_line.py b/backend/app/domains/pipeline/tasks/render_order_line.py index 3743aac..0a22a57 100644 --- a/backend/app/domains/pipeline/tasks/render_order_line.py +++ b/backend/app/domains/pipeline/tasks/render_order_line.py @@ -55,6 +55,14 @@ def dispatch_order_line_render(order_line_id: str): w = int(rs.get("width", 0) or 0) h = int(rs.get("height", 0) or 0) max_dim = max(w, h) + # Apply render_overrides for routing decisions + ro = getattr(line, 'render_overrides', None) or {} + if ro.get("width"): + w = int(ro["width"]) + if ro.get("height"): + h = int(ro["height"]) + if w or h: + max_dim = max(w, h) if max_dim > 0 and max_dim <= 1024 and not is_animation: target_queue = "asset_pipeline_light" @@ -391,6 +399,56 @@ def render_order_line_task(self, order_line_id: str): 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" + # Apply per-line render overrides (format, resolution, samples, etc.) + _render_overrides = getattr(line, 'render_overrides', None) + if _render_overrides and isinstance(_render_overrides, dict): + if 'width' in _render_overrides: + render_width = int(_render_overrides['width']) + if 'height' in _render_overrides: + render_height = int(_render_overrides['height']) + if 'samples' in _render_overrides: + render_samples = int(_render_overrides['samples']) + if 'engine' in _render_overrides: + render_engine = _render_overrides['engine'] + if 'frame_count' in _render_overrides: + frame_count = int(_render_overrides['frame_count']) + if 'fps' in _render_overrides: + fps = int(_render_overrides['fps']) + if 'bg_color' in _render_overrides: + bg_color = _render_overrides['bg_color'] + if 'turntable_axis' in _render_overrides: + turntable_axis = _render_overrides['turntable_axis'] + if 'noise_threshold' in _render_overrides: + noise_threshold = str(_render_overrides['noise_threshold']) + if 'denoiser' in _render_overrides: + denoiser = str(_render_overrides['denoiser']) + if 'denoising_input_passes' in _render_overrides: + denoising_input_passes = str(_render_overrides['denoising_input_passes']) + if 'denoising_prefilter' in _render_overrides: + denoising_prefilter = str(_render_overrides['denoising_prefilter']) + if 'denoising_quality' in _render_overrides: + denoising_quality = str(_render_overrides['denoising_quality']) + if 'denoising_use_gpu' in _render_overrides: + denoising_use_gpu = str(_render_overrides['denoising_use_gpu']) + if 'transparent_bg' in _render_overrides: + transparent_bg = bool(_render_overrides['transparent_bg']) + if 'cycles_device' in _render_overrides: + cycles_device_val = _render_overrides['cycles_device'] + emit(order_line_id, f"Render overrides active: {_render_overrides}") + + # Apply output_format override (affects out_ext and filename) + if 'output_format' in _render_overrides: + fmt_override = _render_overrides['output_format'].lower() + if fmt_override == "mp4": + out_ext = "mp4" + elif fmt_override == "webp": + out_ext = "webp" + elif fmt_override in ("png", "jpg", "jpeg"): + out_ext = "png" if fmt_override == "png" else "jpg" + # Rebuild filename with new extension + filename = f"{_sanitize(product_name)}_{_sanitize(ot_name)}.{out_ext}" + output_path = str(render_dir / filename) + # Build ordered part names list for index-based Blender matching part_names_ordered = None if cad_file and cad_file.parsed_objects: diff --git a/frontend/src/api/orders.ts b/frontend/src/api/orders.ts index 5271422..67f0fa2 100644 --- a/frontend/src/api/orders.ts +++ b/frontend/src/api/orders.ts @@ -56,6 +56,7 @@ export interface OrderLine { render_started_at: string | null render_completed_at: string | null material_override: string | null + render_overrides: Record | null notes: string | null created_at: string updated_at: string @@ -68,6 +69,7 @@ export interface OrderLineCreate { global_render_position_id?: string | null gewuenschte_bildnummer?: string | null material_override?: string | null + render_overrides?: Record | null notes?: string | null } @@ -252,7 +254,7 @@ export async function batchMaterialOverride(orderId: string, materialOverride: s return res.data } -export async function patchOrderLine(orderId: string, lineId: string, data: { material_override?: string | null }) { +export async function patchOrderLine(orderId: string, lineId: string, data: { material_override?: string | null; render_overrides?: Record | null }) { const res = await api.patch<{ updated: boolean; line_id: string }>( `/orders/${orderId}/lines/${lineId}`, data @@ -260,6 +262,14 @@ export async function patchOrderLine(orderId: string, lineId: string, data: { ma return res.data } +export async function batchRenderOverrides(orderId: string, renderOverrides: Record | null) { + const res = await api.post<{ updated: number; render_overrides: Record | null }>( + `/orders/${orderId}/batch-render-overrides`, + { render_overrides: renderOverrides } + ) + return res.data +} + export async function cancelOrderRenders(orderId: string) { const res = await api.post<{ cancelled: number; order_status: string; errors: string[] | null }>( `/orders/${orderId}/cancel-renders` diff --git a/frontend/src/pages/OrderDetail.tsx b/frontend/src/pages/OrderDetail.tsx index 26ac59c..fa1c23e 100644 --- a/frontend/src/pages/OrderDetail.tsx +++ b/frontend/src/pages/OrderDetail.tsx @@ -12,7 +12,7 @@ import { XCircle, RotateCw, Info, } from 'lucide-react' import { toast } from 'sonner' -import { getOrder, submitOrder, deleteOrder, unlinkCadFile, regenerateItemThumbnail, patchOrderItem, removeOrderLine, dispatchRenders, cancelLineRender, dispatchLineRender, cancelOrderRenders, splitMissingStep, generateLinesFromItems, downloadOrderRenders, rejectOrder, resubmitOrder, rejectOrderLine, patchOrderLine, batchMaterialOverride } from '../api/orders' +import { getOrder, submitOrder, deleteOrder, unlinkCadFile, regenerateItemThumbnail, patchOrderItem, removeOrderLine, dispatchRenders, cancelLineRender, dispatchLineRender, cancelOrderRenders, splitMissingStep, generateLinesFromItems, downloadOrderRenders, rejectOrder, resubmitOrder, rejectOrderLine, patchOrderLine, batchMaterialOverride, batchRenderOverrides } from '../api/orders' import { checkOrderMaterials, listMaterials, type UnmappedMaterial, type Material } from '../api/materials' import UnmappedMaterialsDialog from '../components/orders/UnmappedMaterialsDialog' import type { OrderItem, OrderLine } from '../api/orders' @@ -146,6 +146,15 @@ export default function OrderDetailPage() { onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'), }) + const batchRenderOverridesMut = useMutation({ + mutationFn: (val: Record | null) => batchRenderOverrides(id!, val), + onSuccess: (data) => { + toast.success(`Render overrides ${data.render_overrides ? 'set' : 'cleared'} on ${data.updated} lines`) + qc.invalidateQueries({ queryKey: ['order', id] }) + }, + onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'), + }) + const cancelAllMut = useMutation({ mutationFn: () => cancelOrderRenders(id!), onSuccess: (data) => { @@ -652,6 +661,55 @@ export default function OrderDetailPage() { {batchOverrideMut.isPending && } + + {/* Batch Render Overrides */} +
+
+

Batch Render Overrides

+

Override output format or resolution for all lines.

+
+
+ + + {batchRenderOverridesMut.isPending && } +
+
)} diff --git a/schaeffler_mcp_server.py b/schaeffler_mcp_server.py index 9f998bd..85c20ef 100644 --- a/schaeffler_mcp_server.py +++ b/schaeffler_mcp_server.py @@ -294,6 +294,93 @@ def set_material_override(order_id: str, material_name: str = "") -> str: return json.dumps(data, indent=2) +@mcp.tool() +def create_order( + product_ids: list[str], + output_type_id: str = "", + output_type_name: str = "", + render_overrides: dict | None = None, + material_override: str = "", + notes: str = "", +) -> str: + """Create a new order with the given products and output type. + + Either output_type_id or output_type_name must be provided. If both are given, + output_type_id takes priority. render_overrides allows overriding render settings + like output_format, width, height, samples, engine per line. + + Args: + product_ids: List of product UUIDs to include in the order. + output_type_id: UUID of the output type (takes priority over name). + output_type_name: Name of the output type (used if output_type_id is empty). + render_overrides: Optional dict of render setting overrides (e.g. {"output_format": "webp", "width": 2048, "height": 2048}). + material_override: Optional SCHAEFFLER library material name to apply to all lines. + notes: Optional notes for the order. + """ + # Resolve output_type_id from name if needed + ot_id = output_type_id or None + if not ot_id and output_type_name: + rows = _db_query( + "SELECT id FROM output_types WHERE name ILIKE %s AND is_active = true LIMIT 1", + (output_type_name,), + ) + if rows: + ot_id = rows[0]["id"] + else: + return f"Error: No active output type found matching '{output_type_name}'." + + lines = [] + for pid in product_ids: + line: dict = {"product_id": pid} + if ot_id: + line["output_type_id"] = ot_id + if render_overrides: + line["render_overrides"] = render_overrides + if material_override: + line["material_override"] = material_override + lines.append(line) + + body: dict = {"lines": lines} + if notes: + body["notes"] = notes + + try: + data = _api_post("/api/orders", body) + return json.dumps( + { + "order_id": data["id"], + "order_number": data["order_number"], + "status": data["status"], + "line_count": data.get("line_count", len(lines)), + "render_overrides": render_overrides, + }, + indent=2, + ) + except Exception as e: + return f"Error creating order: {e}" + + +@mcp.tool() +def set_render_overrides(order_id: str, render_overrides: dict | None = None) -> str: + """Set render overrides on all lines of an order (batch). + + Overrides any output type render settings at order time. Common overrides: + output_format (png/jpg/webp), width, height, samples, engine (cycles/eevee), + transparent_bg, bg_color, noise_threshold, denoiser. + + Pass None/empty to clear all overrides. + + Args: + order_id: UUID of the order. + render_overrides: Dict of render setting overrides, or None to clear. + """ + data = _api_post( + f"/api/orders/{order_id}/batch-render-overrides", + {"render_overrides": render_overrides}, + ) + return json.dumps(data, indent=2) + + @mcp.tool() def get_queue_status() -> str: """Get current render queue status — pending, active, completed/failed counts."""