feat: per-line render overrides — override any output type setting at order time
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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 ###
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -56,6 +56,7 @@ export interface OrderLine {
|
||||
render_started_at: string | null
|
||||
render_completed_at: string | null
|
||||
material_override: string | null
|
||||
render_overrides: Record<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | null) {
|
||||
const res = await api.post<{ updated: number; render_overrides: Record<string, unknown> | 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`
|
||||
|
||||
@@ -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<string, unknown> | 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 && <Loader2 size={14} className="animate-spin text-accent" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Batch Render Overrides */}
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap mt-3 pt-3 border-t border-border-light">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-content">Batch Render Overrides</p>
|
||||
<p className="text-xs text-content-muted mt-0.5">Override output format or resolution for all lines.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
className="text-sm border border-border-default rounded-lg px-3 py-1.5"
|
||||
style={{ backgroundColor: 'var(--color-bg-surface)' }}
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
if (val === '__clear__') batchRenderOverridesMut.mutate(null)
|
||||
else if (val === 'webp') batchRenderOverridesMut.mutate({ output_format: 'webp' })
|
||||
else if (val === 'png') batchRenderOverridesMut.mutate({ output_format: 'png' })
|
||||
else if (val === 'jpg') batchRenderOverridesMut.mutate({ output_format: 'jpg' })
|
||||
}}
|
||||
disabled={batchRenderOverridesMut.isPending}
|
||||
>
|
||||
<option value="">Output format...</option>
|
||||
<option value="__clear__">-- Clear all overrides --</option>
|
||||
<option value="png">PNG</option>
|
||||
<option value="jpg">JPG</option>
|
||||
<option value="webp">WebP</option>
|
||||
</select>
|
||||
<select
|
||||
className="text-sm border border-border-default rounded-lg px-3 py-1.5"
|
||||
style={{ backgroundColor: 'var(--color-bg-surface)' }}
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
if (!val) return
|
||||
const [w, h] = val.split('x').map(Number)
|
||||
batchRenderOverridesMut.mutate({ width: w, height: h })
|
||||
}}
|
||||
disabled={batchRenderOverridesMut.isPending}
|
||||
>
|
||||
<option value="">Resolution...</option>
|
||||
<option value="512x512">512 x 512</option>
|
||||
<option value="1024x1024">1024 x 1024</option>
|
||||
<option value="1920x1920">1920 x 1920</option>
|
||||
<option value="2048x2048">2048 x 2048</option>
|
||||
<option value="4096x4096">4096 x 4096</option>
|
||||
</select>
|
||||
{batchRenderOverridesMut.isPending && <Loader2 size={14} className="animate-spin text-accent" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user