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:
2026-03-15 12:26:38 +01:00
parent 5a148554c0
commit b892f72f7e
9 changed files with 281 additions and 2 deletions
@@ -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 ###
+30
View File
@@ -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}")
+1
View File
@@ -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
+2
View File
@@ -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: