feat: make output types workflow-first contracts

This commit is contained in:
2026-04-08 21:43:55 +02:00
parent bd18cccb5e
commit 8c9648d5dc
8 changed files with 1049 additions and 110 deletions
@@ -0,0 +1,57 @@
"""Add workflow contract fields to output_types.
Revision ID: 065
Revises: 064
"""
from alembic import op
import sqlalchemy as sa
revision = "065"
down_revision = "064"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"output_types",
sa.Column("workflow_family", sa.String(length=20), nullable=False, server_default="order_line"),
)
op.add_column(
"output_types",
sa.Column("artifact_kind", sa.String(length=50), nullable=False, server_default="still_image"),
)
op.execute(
"""
UPDATE output_types
SET workflow_family = CASE
WHEN lower(coalesce(output_format, '')) IN ('gltf', 'glb', 'stl', 'obj', 'usd', 'usdz')
THEN 'cad_file'
ELSE 'order_line'
END
"""
)
op.execute(
"""
UPDATE output_types
SET artifact_kind = CASE
WHEN is_animation IS TRUE OR lower(coalesce(output_format, '')) IN ('mp4', 'webm', 'mov')
THEN 'turntable_video'
WHEN lower(coalesce(output_format, '')) IN ('gltf', 'glb', 'stl', 'obj', 'usd', 'usdz')
THEN 'model_export'
WHEN workflow_family = 'cad_file' AND lower(coalesce(output_format, '')) IN ('png', 'jpg', 'jpeg', 'webp')
THEN 'thumbnail_image'
ELSE 'still_image'
END
"""
)
op.alter_column("output_types", "workflow_family", server_default=None)
op.alter_column("output_types", "artifact_kind", server_default=None)
def downgrade() -> None:
op.drop_column("output_types", "artifact_kind")
op.drop_column("output_types", "workflow_family")
@@ -0,0 +1,72 @@
"""Add invocation overrides to output_types.
Revision ID: 066
Revises: 065
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "066"
down_revision = "065"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"output_types",
sa.Column(
"invocation_overrides",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
server_default=sa.text("'{}'::jsonb"),
),
)
op.execute(
"""
UPDATE output_types
SET invocation_overrides = COALESCE(jsonb_strip_nulls(jsonb_build_object(
'width', CASE
WHEN coalesce(render_settings->>'width', '') ~ '^[0-9]+$'
THEN (render_settings->>'width')::int
ELSE NULL
END,
'height', CASE
WHEN coalesce(render_settings->>'height', '') ~ '^[0-9]+$'
THEN (render_settings->>'height')::int
ELSE NULL
END,
'engine', NULLIF(render_settings->>'engine', ''),
'samples', CASE
WHEN coalesce(render_settings->>'samples', '') ~ '^[0-9]+$'
THEN (render_settings->>'samples')::int
ELSE NULL
END,
'frame_count', CASE
WHEN coalesce(render_settings->>'frame_count', '') ~ '^[0-9]+$'
THEN (render_settings->>'frame_count')::int
ELSE NULL
END,
'fps', CASE
WHEN coalesce(render_settings->>'fps', '') ~ '^[0-9]+$'
THEN (render_settings->>'fps')::int
ELSE NULL
END,
'turntable_axis', NULLIF(render_settings->>'turntable_axis', ''),
'bg_color', NULLIF(render_settings->>'bg_color', ''),
'noise_threshold', NULLIF(render_settings->>'noise_threshold', ''),
'denoiser', NULLIF(render_settings->>'denoiser', ''),
'denoising_input_passes', NULLIF(render_settings->>'denoising_input_passes', ''),
'denoising_prefilter', NULLIF(render_settings->>'denoising_prefilter', ''),
'denoising_quality', NULLIF(render_settings->>'denoising_quality', ''),
'denoising_use_gpu', NULLIF(render_settings->>'denoising_use_gpu', '')
)), '{}'::jsonb)
"""
)
op.alter_column("output_types", "invocation_overrides", server_default=None)
def downgrade() -> None:
op.drop_column("output_types", "invocation_overrides")
+159 -2
View File
@@ -9,11 +9,24 @@ from sqlalchemy.dialects.postgresql import JSONB
from app.database import get_db
from app.models.order_line import OrderLine
from app.models.output_type import OutputType, VALID_RENDER_BACKENDS
from app.models.output_type import (
OUTPUT_TYPE_ARTIFACT_KINDS,
OUTPUT_TYPE_WORKFLOW_FAMILIES,
OutputType,
VALID_RENDER_BACKENDS,
)
from app.schemas.output_type import OutputTypeCreate, OutputTypeOut, OutputTypePatch
from app.utils.auth import get_current_user, require_admin_or_pm
from app.models.user import User
from app.domains.rendering.models import WorkflowDefinition
from app.domains.rendering.output_type_contracts import (
apply_invocation_overrides_to_render_settings,
infer_output_type_artifact_kind,
infer_workflow_family_from_config,
merge_output_type_invocation_overrides,
normalize_invocation_overrides,
validate_output_type_contract,
)
router = APIRouter(prefix="/output-types", tags=["output-types"])
@@ -44,6 +57,54 @@ async def _enrich_workflow_names(db: AsyncSession, items: list[OutputTypeOut]) -
return items
async def _validate_output_type_workflow_link(
db: AsyncSession,
*,
workflow_definition_id: uuid.UUID | None,
workflow_family: str,
) -> None:
if workflow_definition_id is None:
return
workflow_definition = await db.get(WorkflowDefinition, workflow_definition_id)
if workflow_definition is None:
raise HTTPException(400, detail="Workflow definition not found")
try:
workflow_family_inferred = infer_workflow_family_from_config(workflow_definition.config)
except Exception as exc:
raise HTTPException(400, detail=f"Workflow definition has invalid config: {exc}") from exc
if workflow_family_inferred == "mixed":
raise HTTPException(400, detail="Output types cannot link mixed-family workflows")
if workflow_family_inferred is not None and workflow_family_inferred != workflow_family:
raise HTTPException(
400,
detail=(
f"Workflow family mismatch: output type expects '{workflow_family}', "
f"but workflow '{workflow_definition.name}' is '{workflow_family_inferred}'"
),
)
def _ensure_output_type_contract_is_valid(
*,
workflow_family: str,
artifact_kind: str,
output_format: str | None,
is_animation: bool,
) -> None:
try:
validate_output_type_contract(
workflow_family=workflow_family,
artifact_kind=artifact_kind,
output_format=output_format,
is_animation=is_animation,
)
except ValueError as exc:
raise HTTPException(400, detail=str(exc)) from exc
@router.get("", response_model=list[OutputTypeOut])
async def list_output_types(
include_inactive: bool = Query(False),
@@ -80,12 +141,48 @@ async def create_output_type(
):
if body.render_backend not in VALID_RENDER_BACKENDS:
raise HTTPException(400, detail=f"Invalid render_backend. Choose: {', '.join(sorted(VALID_RENDER_BACKENDS))}")
if body.workflow_family not in OUTPUT_TYPE_WORKFLOW_FAMILIES:
raise HTTPException(
400,
detail=f"Invalid workflow_family. Choose: {', '.join(sorted(OUTPUT_TYPE_WORKFLOW_FAMILIES))}",
)
existing = await db.execute(select(OutputType).where(OutputType.name == body.name))
if existing.scalar_one_or_none():
raise HTTPException(409, detail=f"Output type '{body.name}' already exists")
ot = OutputType(**body.model_dump())
data = body.model_dump()
explicit_invocation = normalize_invocation_overrides(body.invocation_overrides)
if not explicit_invocation:
explicit_invocation = normalize_invocation_overrides(body.render_settings)
data["invocation_overrides"] = explicit_invocation
data["render_settings"] = apply_invocation_overrides_to_render_settings(
body.render_settings,
explicit_invocation,
)
data["artifact_kind"] = data.get("artifact_kind") or infer_output_type_artifact_kind(
body.output_format,
body.is_animation,
body.workflow_family,
)
if data["artifact_kind"] not in OUTPUT_TYPE_ARTIFACT_KINDS:
raise HTTPException(
400,
detail=f"Invalid artifact_kind. Choose: {', '.join(sorted(OUTPUT_TYPE_ARTIFACT_KINDS))}",
)
_ensure_output_type_contract_is_valid(
workflow_family=body.workflow_family,
artifact_kind=data["artifact_kind"],
output_format=body.output_format,
is_animation=body.is_animation,
)
await _validate_output_type_workflow_link(
db,
workflow_definition_id=body.workflow_definition_id,
workflow_family=body.workflow_family,
)
ot = OutputType(**data)
db.add(ot)
await db.commit()
await db.refresh(ot)
@@ -112,6 +209,66 @@ async def update_output_type(
data = body.model_dump(exclude_unset=True)
if "render_backend" in data and data["render_backend"] not in VALID_RENDER_BACKENDS:
raise HTTPException(400, detail=f"Invalid render_backend. Choose: {', '.join(sorted(VALID_RENDER_BACKENDS))}")
if "workflow_family" in data and data["workflow_family"] not in OUTPUT_TYPE_WORKFLOW_FAMILIES:
raise HTTPException(
400,
detail=f"Invalid workflow_family. Choose: {', '.join(sorted(OUTPUT_TYPE_WORKFLOW_FAMILIES))}",
)
candidate_workflow_family = data.get("workflow_family", ot.workflow_family)
candidate_workflow_definition_id = data.get("workflow_definition_id", ot.workflow_definition_id)
candidate_output_format = data.get("output_format", ot.output_format)
candidate_is_animation = data.get("is_animation", ot.is_animation)
candidate_artifact_kind = data.get("artifact_kind", ot.artifact_kind)
render_settings_supplied = "render_settings" in data
invocation_supplied = "invocation_overrides" in data
if render_settings_supplied or invocation_supplied:
candidate_render_settings = data.get("render_settings", ot.render_settings)
if invocation_supplied:
candidate_invocation_overrides = normalize_invocation_overrides(data.get("invocation_overrides"))
else:
candidate_invocation_overrides = merge_output_type_invocation_overrides(
candidate_render_settings,
None,
)
data["invocation_overrides"] = candidate_invocation_overrides
data["render_settings"] = apply_invocation_overrides_to_render_settings(
candidate_render_settings,
candidate_invocation_overrides,
)
should_recompute_artifact_kind = (
"artifact_kind" in data
or "workflow_family" in data
or "output_format" in data
or "is_animation" in data
)
if should_recompute_artifact_kind:
data["artifact_kind"] = data.get("artifact_kind") or infer_output_type_artifact_kind(
candidate_output_format,
candidate_is_animation,
candidate_workflow_family,
)
candidate_artifact_kind = data["artifact_kind"]
if candidate_artifact_kind not in OUTPUT_TYPE_ARTIFACT_KINDS:
raise HTTPException(
400,
detail=f"Invalid artifact_kind. Choose: {', '.join(sorted(OUTPUT_TYPE_ARTIFACT_KINDS))}",
)
_ensure_output_type_contract_is_valid(
workflow_family=candidate_workflow_family,
artifact_kind=candidate_artifact_kind,
output_format=candidate_output_format,
is_animation=candidate_is_animation,
)
await _validate_output_type_workflow_link(
db,
workflow_definition_id=candidate_workflow_definition_id,
workflow_family=candidate_workflow_family,
)
for field_name, value in data.items():
setattr(ot, field_name, value)
@@ -0,0 +1,161 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any, Literal
from app.domains.rendering.workflow_config_utils import canonicalize_workflow_config
from app.domains.rendering.workflow_node_registry import get_node_definition
ResolvedWorkflowFamily = Literal["cad_file", "order_line", "mixed"]
OutputTypeWorkflowFamily = Literal["cad_file", "order_line"]
OutputTypeArtifactKind = Literal[
"still_image",
"turntable_video",
"model_export",
"thumbnail_image",
"blend_asset",
"package",
"custom",
]
_MODEL_EXPORT_FORMATS = {"gltf", "glb", "stl", "obj", "usd", "usdz"}
_VIDEO_FORMATS = {"mp4", "webm", "mov"}
_IMAGE_FORMATS = {"png", "jpg", "jpeg", "webp"}
_ARTIFACT_KINDS_BY_FAMILY: dict[OutputTypeWorkflowFamily, set[OutputTypeArtifactKind]] = {
"cad_file": {"thumbnail_image", "model_export", "package", "custom"},
"order_line": {"still_image", "turntable_video", "blend_asset", "package", "custom"},
}
INVOCATION_OVERRIDE_KEYS = (
"width",
"height",
"engine",
"samples",
"frame_count",
"fps",
"turntable_axis",
"bg_color",
"noise_threshold",
"denoiser",
"denoising_input_passes",
"denoising_prefilter",
"denoising_quality",
"denoising_use_gpu",
)
def list_allowed_artifact_kinds_for_family(
workflow_family: str,
) -> tuple[OutputTypeArtifactKind, ...]:
normalized_family = (workflow_family or "order_line").strip().lower()
if normalized_family == "cad_file":
allowed = _ARTIFACT_KINDS_BY_FAMILY["cad_file"]
else:
allowed = _ARTIFACT_KINDS_BY_FAMILY["order_line"]
return tuple(sorted(allowed))
def infer_output_type_artifact_kind(
output_format: str | None,
is_animation: bool,
workflow_family: str = "order_line",
) -> str:
normalized_format = (output_format or "").strip().lower()
normalized_family = (workflow_family or "order_line").strip().lower()
if is_animation or normalized_format in _VIDEO_FORMATS:
return "turntable_video"
if normalized_format in _MODEL_EXPORT_FORMATS:
return "model_export"
if normalized_family == "cad_file" and normalized_format in _IMAGE_FORMATS:
return "thumbnail_image"
return "still_image"
def validate_output_type_contract(
*,
workflow_family: str,
artifact_kind: str,
output_format: str | None,
is_animation: bool,
) -> None:
normalized_family = (workflow_family or "order_line").strip().lower()
normalized_artifact = (artifact_kind or "").strip().lower()
normalized_format = (output_format or "").strip().lower()
allowed_artifact_kinds = list_allowed_artifact_kinds_for_family(normalized_family)
if normalized_artifact not in allowed_artifact_kinds:
allowed = ", ".join(allowed_artifact_kinds)
raise ValueError(
f"Artifact kind '{artifact_kind}' is not allowed for workflow_family "
f"'{workflow_family}'. Allowed: {allowed}"
)
if normalized_family == "cad_file" and is_animation:
raise ValueError("CAD-file workflows do not support animated output types")
if normalized_artifact == "turntable_video":
if not is_animation:
raise ValueError("Artifact kind 'turntable_video' requires is_animation=true")
if normalized_format and normalized_format not in _VIDEO_FORMATS:
raise ValueError(
"Artifact kind 'turntable_video' requires a video output_format "
f"({', '.join(sorted(_VIDEO_FORMATS))})"
)
if normalized_artifact in {"still_image", "thumbnail_image"} and normalized_format in _VIDEO_FORMATS:
raise ValueError(
f"Artifact kind '{artifact_kind}' is incompatible with video output_format '{output_format}'"
)
if normalized_artifact == "model_export" and normalized_format and normalized_format not in _MODEL_EXPORT_FORMATS:
raise ValueError(
"Artifact kind 'model_export' requires a model output_format "
f"({', '.join(sorted(_MODEL_EXPORT_FORMATS))})"
)
def infer_workflow_family_from_config(config: dict) -> ResolvedWorkflowFamily | None:
normalized = canonicalize_workflow_config(config)
families = {
definition.family
for node in normalized.get("nodes", [])
if (definition := get_node_definition(node.get("step"))) is not None
}
if not families:
return None
if len(families) > 1:
return "mixed"
return next(iter(families))
def normalize_invocation_overrides(raw: Mapping[str, Any] | None) -> dict[str, Any]:
if not isinstance(raw, Mapping):
return {}
normalized: dict[str, Any] = {}
for key in INVOCATION_OVERRIDE_KEYS:
value = raw.get(key)
if value not in (None, ""):
normalized[key] = value
return normalized
def merge_output_type_invocation_overrides(
render_settings: Mapping[str, Any] | None,
invocation_overrides: Mapping[str, Any] | None,
) -> dict[str, Any]:
merged = normalize_invocation_overrides(render_settings)
merged.update(normalize_invocation_overrides(invocation_overrides))
return merged
def apply_invocation_overrides_to_render_settings(
render_settings: Mapping[str, Any] | None,
invocation_overrides: Mapping[str, Any] | None,
) -> dict[str, Any]:
merged = dict(render_settings or {})
normalized_invocation = normalize_invocation_overrides(invocation_overrides)
for key in INVOCATION_OVERRIDE_KEYS:
merged.pop(key, None)
merged.update(normalized_invocation)
return merged
+12 -2
View File
@@ -1,3 +1,13 @@
# Compat shim — use app.domains.rendering.models instead
from app.domains.rendering.models import OutputType, VALID_RENDER_BACKENDS
__all__ = ["OutputType", "VALID_RENDER_BACKENDS"]
from app.domains.rendering.models import (
OUTPUT_TYPE_ARTIFACT_KINDS,
OUTPUT_TYPE_WORKFLOW_FAMILIES,
OutputType,
VALID_RENDER_BACKENDS,
)
__all__ = [
"OutputType",
"VALID_RENDER_BACKENDS",
"OUTPUT_TYPE_WORKFLOW_FAMILIES",
"OUTPUT_TYPE_ARTIFACT_KINDS",
]
@@ -0,0 +1,268 @@
from __future__ import annotations
import uuid
import pytest
from app.domains.rendering.models import WorkflowDefinition
from app.domains.rendering.workflow_config_utils import (
build_preset_workflow_config,
build_workflow_blueprint_config,
)
@pytest.mark.asyncio
async def test_create_output_type_infers_artifact_kind_from_format_and_animation(
client,
db,
auth_headers,
):
response = await client.post(
"/api/output-types",
json={
"name": f"Turntable {uuid.uuid4().hex[:8]}",
"renderer": "blender",
"output_format": "mp4",
"render_backend": "celery",
"workflow_family": "order_line",
"is_animation": True,
},
headers=auth_headers,
)
assert response.status_code == 201, response.text
payload = response.json()
assert payload["workflow_family"] == "order_line"
assert payload["artifact_kind"] == "turntable_video"
assert payload["invocation_overrides"] == {}
@pytest.mark.asyncio
async def test_create_output_type_rejects_workflow_family_mismatch(
client,
db,
auth_headers,
):
workflow = WorkflowDefinition(
name=f"CAD Intake {uuid.uuid4().hex[:8]}",
config=build_workflow_blueprint_config("cad_intake"),
is_active=True,
)
db.add(workflow)
await db.commit()
await db.refresh(workflow)
response = await client.post(
"/api/output-types",
json={
"name": f"Still {uuid.uuid4().hex[:8]}",
"renderer": "blender",
"output_format": "png",
"render_backend": "celery",
"workflow_family": "order_line",
"workflow_definition_id": str(workflow.id),
},
headers=auth_headers,
)
assert response.status_code == 400, response.text
assert "Workflow family mismatch" in response.json()["detail"]
@pytest.mark.asyncio
async def test_create_output_type_rejects_artifact_kind_incompatible_with_family(
client,
auth_headers,
):
response = await client.post(
"/api/output-types",
json={
"name": f"Bad Thumbnail {uuid.uuid4().hex[:8]}",
"renderer": "blender",
"output_format": "png",
"render_backend": "celery",
"workflow_family": "order_line",
"artifact_kind": "thumbnail_image",
},
headers=auth_headers,
)
assert response.status_code == 400, response.text
assert "not allowed for workflow_family" in response.json()["detail"]
@pytest.mark.asyncio
async def test_create_output_type_rejects_turntable_video_without_animation(
client,
auth_headers,
):
response = await client.post(
"/api/output-types",
json={
"name": f"Bad Turntable {uuid.uuid4().hex[:8]}",
"renderer": "blender",
"output_format": "mp4",
"render_backend": "celery",
"workflow_family": "order_line",
"artifact_kind": "turntable_video",
"is_animation": False,
},
headers=auth_headers,
)
assert response.status_code == 400, response.text
assert response.json()["detail"] == "Artifact kind 'turntable_video' requires is_animation=true"
@pytest.mark.asyncio
async def test_update_output_type_rejects_mixed_family_workflow(
client,
db,
auth_headers,
):
output_type_response = await client.post(
"/api/output-types",
json={
"name": f"Still {uuid.uuid4().hex[:8]}",
"renderer": "blender",
"output_format": "png",
"render_backend": "celery",
"workflow_family": "order_line",
},
headers=auth_headers,
)
assert output_type_response.status_code == 201, output_type_response.text
output_type = output_type_response.json()
workflow = WorkflowDefinition(
name=f"Mixed {uuid.uuid4().hex[:8]}",
config={
"version": 1,
"nodes": build_workflow_blueprint_config("cad_intake")["nodes"][:1]
+ build_preset_workflow_config("still_graph")["nodes"][:1],
"edges": [],
"ui": {"preset": "custom", "execution_mode": "graph"},
},
is_active=True,
)
db.add(workflow)
await db.commit()
await db.refresh(workflow)
response = await client.patch(
f"/api/output-types/{output_type['id']}",
json={"workflow_definition_id": str(workflow.id)},
headers=auth_headers,
)
assert response.status_code == 400, response.text
assert response.json()["detail"] == "Output types cannot link mixed-family workflows"
@pytest.mark.asyncio
async def test_create_output_type_backfills_invocation_overrides_from_legacy_render_settings(
client,
auth_headers,
):
response = await client.post(
"/api/output-types",
json={
"name": f"Legacy Still {uuid.uuid4().hex[:8]}",
"renderer": "blender",
"output_format": "png",
"render_backend": "celery",
"workflow_family": "order_line",
"render_settings": {
"width": 1600,
"height": 900,
"engine": "cycles",
},
},
headers=auth_headers,
)
assert response.status_code == 201, response.text
payload = response.json()
assert payload["artifact_kind"] == "still_image"
assert payload["invocation_overrides"] == {
"width": 1600,
"height": 900,
"engine": "cycles",
}
assert payload["render_settings"]["width"] == 1600
assert payload["render_settings"]["height"] == 900
assert payload["render_settings"]["engine"] == "cycles"
@pytest.mark.asyncio
async def test_patch_output_type_invocation_overrides_syncs_legacy_render_settings(
client,
auth_headers,
):
output_type_response = await client.post(
"/api/output-types",
json={
"name": f"Still {uuid.uuid4().hex[:8]}",
"renderer": "blender",
"output_format": "png",
"render_backend": "celery",
"workflow_family": "order_line",
},
headers=auth_headers,
)
assert output_type_response.status_code == 201, output_type_response.text
output_type = output_type_response.json()
response = await client.patch(
f"/api/output-types/{output_type['id']}",
json={
"invocation_overrides": {
"width": 1600,
"height": 900,
"engine": "cycles",
}
},
headers=auth_headers,
)
assert response.status_code == 200, response.text
payload = response.json()
assert payload["invocation_overrides"]["width"] == 1600
assert payload["invocation_overrides"]["height"] == 900
assert payload["invocation_overrides"]["engine"] == "cycles"
assert payload["render_settings"]["width"] == 1600
assert payload["render_settings"]["height"] == 900
assert payload["render_settings"]["engine"] == "cycles"
@pytest.mark.asyncio
async def test_patch_output_type_recomputes_artifact_kind_when_switching_family(
client,
auth_headers,
):
output_type_response = await client.post(
"/api/output-types",
json={
"name": f"Still {uuid.uuid4().hex[:8]}",
"renderer": "blender",
"output_format": "png",
"render_backend": "celery",
"workflow_family": "order_line",
},
headers=auth_headers,
)
assert output_type_response.status_code == 201, output_type_response.text
output_type = output_type_response.json()
response = await client.patch(
f"/api/output-types/{output_type['id']}",
json={
"workflow_family": "cad_file",
},
headers=auth_headers,
)
assert response.status_code == 200, response.text
payload = response.json()
assert payload["workflow_family"] == "cad_file"
assert payload["artifact_kind"] == "thumbnail_image"
+77
View File
@@ -1,22 +1,56 @@
import api from './client'
export type OutputTypeWorkflowFamily = 'cad_file' | 'order_line'
export type OutputTypeArtifactKind =
| 'still_image'
| 'turntable_video'
| 'model_export'
| 'thumbnail_image'
| 'blend_asset'
| 'package'
| 'custom'
export const OUTPUT_TYPE_INVOCATION_OVERRIDE_KEYS = [
'width',
'height',
'engine',
'samples',
'frame_count',
'fps',
'turntable_axis',
'bg_color',
'noise_threshold',
'denoiser',
'denoising_input_passes',
'denoising_prefilter',
'denoising_quality',
'denoising_use_gpu',
] as const
const CAD_FILE_ARTIFACT_KINDS: OutputTypeArtifactKind[] = ['thumbnail_image', 'model_export', 'package', 'custom']
const ORDER_LINE_ARTIFACT_KINDS: OutputTypeArtifactKind[] = ['still_image', 'turntable_video', 'blend_asset', 'package', 'custom']
export interface OutputType {
id: string
name: string
description: string | null
renderer: string
render_settings: Record<string, unknown>
invocation_overrides: Record<string, unknown>
output_format: string
sort_order: number
compatible_categories: string[]
render_backend: string
is_animation: boolean
transparent_bg: boolean
workflow_family: OutputTypeWorkflowFamily
artifact_kind: OutputTypeArtifactKind
cycles_device: string | null
pricing_tier_id: number | null
pricing_tier_name: string | null
price_per_item: number | null
workflow_definition_id: string | null
workflow_name?: string | null
material_override: string | null
is_active: boolean
created_at: string
@@ -46,3 +80,46 @@ export async function updateOutputType(id: string, data: Partial<OutputType>): P
export async function deleteOutputType(id: string): Promise<void> {
await api.delete(`/output-types/${id}`)
}
export function listAllowedArtifactKindsForFamily(family: OutputTypeWorkflowFamily): OutputTypeArtifactKind[] {
return family === 'cad_file' ? [...CAD_FILE_ARTIFACT_KINDS] : [...ORDER_LINE_ARTIFACT_KINDS]
}
export function inferArtifactKind(
workflowFamily: OutputTypeWorkflowFamily,
outputFormat: string,
isAnimation: boolean,
): OutputTypeArtifactKind {
const normalizedFormat = outputFormat.trim().toLowerCase()
if (isAnimation || ['mp4', 'webm', 'mov'].includes(normalizedFormat)) {
return 'turntable_video'
}
if (['gltf', 'glb', 'stl', 'obj', 'usd', 'usdz'].includes(normalizedFormat)) {
return 'model_export'
}
if (workflowFamily === 'cad_file') {
return 'thumbnail_image'
}
return 'still_image'
}
export function isArtifactKindAllowedForFamily(
workflowFamily: OutputTypeWorkflowFamily,
artifactKind: OutputTypeArtifactKind,
): boolean {
return listAllowedArtifactKindsForFamily(workflowFamily).includes(artifactKind)
}
export function getOutputTypeInvocationOverrides(outputType: OutputType): Record<string, unknown> {
const normalized: Record<string, unknown> = {}
for (const key of OUTPUT_TYPE_INVOCATION_OVERRIDE_KEYS) {
const explicitValue = outputType.invocation_overrides?.[key]
const legacyValue = outputType.render_settings?.[key]
const value = explicitValue ?? legacyValue
if (value !== undefined && value !== null && value !== '') {
normalized[key] = value
}
}
return normalized
}
+243 -106
View File
@@ -4,17 +4,34 @@ import { Pencil, Trash2, Plus, Check, X, ChevronDown, Copy } from 'lucide-react'
import { toast } from 'sonner'
import {
listOutputTypes, createOutputType, updateOutputType, deleteOutputType,
getOutputTypeInvocationOverrides,
inferArtifactKind,
isArtifactKindAllowedForFamily,
listAllowedArtifactKindsForFamily,
} from '../../api/outputTypes'
import type { OutputType } from '../../api/outputTypes'
import type { OutputType, OutputTypeArtifactKind, OutputTypeWorkflowFamily } from '../../api/outputTypes'
import { listMaterials } from '../../api/materials'
import type { Material } from '../../api/materials'
import { listPricingTiers } from '../../api/pricing'
import type { PricingTier } from '../../api/pricing'
import { getWorkflows } from '../../api/workflows'
import { getWorkflows, inferWorkflowFamily as inferWorkflowFamilyFromConfig } from '../../api/workflows'
import type { WorkflowDefinition } from '../../api/workflows'
const RENDERERS = ['blender', 'pillow']
const RENDERERS = ['blender']
const FORMATS = ['png', 'jpg', 'gltf', 'stl', 'mp4', 'webm']
const WORKFLOW_FAMILIES = [
{ value: 'order_line', label: 'Order Rendering' },
{ value: 'cad_file', label: 'CAD Intake' },
] as const
const ARTIFACT_KINDS = [
{ value: 'still_image', label: 'Still Image' },
{ value: 'turntable_video', label: 'Turntable Video' },
{ value: 'model_export', label: 'Model Export' },
{ value: 'thumbnail_image', label: 'Thumbnail Image' },
{ value: 'blend_asset', label: 'Blend Asset' },
{ value: 'package', label: 'Package' },
{ value: 'custom', label: 'Custom' },
] as const
const ALL_CATEGORIES = [
{ key: 'TRB', label: 'TRB' },
{ key: 'Kugellager', label: 'Kugellager' },
@@ -24,7 +41,30 @@ const ALL_CATEGORIES = [
{ key: 'Linear_schiene', label: 'Linear' },
{ key: 'Anschlagplatten', label: 'Anschlag' },
]
const EMPTY_FORM ={ name: '', description: '', renderer: 'threejs', output_format: 'png', sort_order: 0, compatible_categories: [] as string[], render_backend: 'auto', is_animation: false, transparent_bg: false, cycles_device: '' as string, pricing_tier_id: null as number | null, material_override: '' as string, width: '', height: '', engine: '', samples: '', frame_count: '', fps: '', turntable_axis: 'world_z', bg_color: '', noise_threshold: '', denoiser: '', denoising_input_passes: '', denoising_prefilter: '', denoising_quality: '', denoising_use_gpu: '' }
const EMPTY_FORM ={ name: '', description: '', renderer: 'blender', output_format: 'png', sort_order: 0, compatible_categories: [] as string[], render_backend: 'celery', is_animation: false, transparent_bg: false, workflow_family: 'order_line' as OutputTypeWorkflowFamily, artifact_kind: 'still_image' as OutputTypeArtifactKind, workflow_definition_id: '' as string, cycles_device: '' as string, pricing_tier_id: null as number | null, material_override: '' as string, width: '', height: '', engine: '', samples: '', frame_count: '', fps: '', turntable_axis: 'world_z', bg_color: '', noise_threshold: '', denoiser: '', denoising_input_passes: '', denoising_prefilter: '', denoising_quality: '', denoising_use_gpu: '' }
function getWorkflowFamily(workflow: WorkflowDefinition): 'cad_file' | 'order_line' | 'mixed' | null {
return workflow.family ?? inferWorkflowFamilyFromConfig(workflow.config)
}
function buildInvocationOverridesFromValues(values: Record<string, unknown>): Record<string, unknown> {
const overrides: Record<string, unknown> = {}
if (values.width) overrides.width = Number(values.width)
if (values.height) overrides.height = Number(values.height)
if (values.engine) overrides.engine = values.engine
if (values.samples) overrides.samples = Number(values.samples)
if (values.frame_count) overrides.frame_count = Number(values.frame_count)
if (values.fps) overrides.fps = Number(values.fps)
if (values.turntable_axis) overrides.turntable_axis = values.turntable_axis
if (values.bg_color) overrides.bg_color = values.bg_color
if (values.noise_threshold) overrides.noise_threshold = values.noise_threshold
if (values.denoiser) overrides.denoiser = values.denoiser
if (values.denoising_input_passes) overrides.denoising_input_passes = values.denoising_input_passes
if (values.denoising_prefilter) overrides.denoising_prefilter = values.denoising_prefilter
if (values.denoising_quality) overrides.denoising_quality = values.denoising_quality
if (values.denoising_use_gpu) overrides.denoising_use_gpu = values.denoising_use_gpu
return overrides
}
export default function OutputTypeTable() {
const qc = useQueryClient()
@@ -54,6 +94,14 @@ export default function OutputTypeTable() {
queryFn: getWorkflows,
})
const workflowsByFamily = (workflows ?? []).filter(w => w.is_active).reduce<Record<string, WorkflowDefinition[]>>((acc, workflow) => {
const family = getWorkflowFamily(workflow)
if (family === null) return acc
if (!acc[family]) acc[family] = []
acc[family].push(workflow)
return acc
}, {})
const updateWorkflowMut = useMutation({
mutationFn: ({ id, workflow_definition_id }: { id: string; workflow_definition_id: string | null }) =>
updateOutputType(id, { workflow_definition_id }),
@@ -67,23 +115,22 @@ export default function OutputTypeTable() {
const createMut = useMutation({
mutationFn: () => {
const rs: Record<string, unknown> = {}
if (form.width) rs.width = Number(form.width)
if (form.height) rs.height = Number(form.height)
if (form.engine) rs.engine = form.engine
if (form.samples) rs.samples = Number(form.samples)
if (form.is_animation) {
if (form.frame_count) rs.frame_count = Number(form.frame_count)
if (form.fps) rs.fps = Number(form.fps)
if (form.turntable_axis) rs.turntable_axis = form.turntable_axis
if (form.bg_color) rs.bg_color = form.bg_color
}
if (form.noise_threshold) rs.noise_threshold = form.noise_threshold
if (form.denoiser) rs.denoiser = form.denoiser
if (form.denoising_input_passes) rs.denoising_input_passes = form.denoising_input_passes
if (form.denoising_prefilter) rs.denoising_prefilter = form.denoising_prefilter
if (form.denoising_quality) rs.denoising_quality = form.denoising_quality
if (form.denoising_use_gpu) rs.denoising_use_gpu = form.denoising_use_gpu
const invocationOverrides = buildInvocationOverridesFromValues({
width: form.width,
height: form.height,
engine: form.engine,
samples: form.samples,
frame_count: form.is_animation ? form.frame_count : '',
fps: form.is_animation ? form.fps : '',
turntable_axis: form.is_animation ? form.turntable_axis : '',
bg_color: form.bg_color,
noise_threshold: form.noise_threshold,
denoiser: form.denoiser,
denoising_input_passes: form.denoising_input_passes,
denoising_prefilter: form.denoising_prefilter,
denoising_quality: form.denoising_quality,
denoising_use_gpu: form.denoising_use_gpu,
})
return createOutputType({
name: form.name.trim(),
description: form.description.trim() || undefined,
@@ -94,10 +141,13 @@ export default function OutputTypeTable() {
render_backend: form.render_backend,
is_animation: form.is_animation,
transparent_bg: form.transparent_bg,
workflow_family: form.workflow_family,
artifact_kind: form.artifact_kind,
invocation_overrides: invocationOverrides,
workflow_definition_id: form.workflow_definition_id || null,
cycles_device: form.cycles_device || null,
pricing_tier_id: form.pricing_tier_id,
material_override: form.material_override || null,
render_settings: Object.keys(rs).length > 0 ? rs : {},
} as Partial<OutputType>)
},
onSuccess: () => {
@@ -115,7 +165,7 @@ export default function OutputTypeTable() {
const { _width, _height, _engine, _samples, _frame_count, _fps, _turntable_axis, _bg_color, _noise_threshold, _denoiser, _denoising_input_passes, _denoising_prefilter, _denoising_quality, _denoising_use_gpu, ...rest } = data
if (_width !== undefined || _height !== undefined || _engine !== undefined || _samples !== undefined || _frame_count !== undefined || _fps !== undefined || _turntable_axis !== undefined || _bg_color !== undefined || _noise_threshold !== undefined || _denoiser !== undefined || _denoising_input_passes !== undefined || _denoising_prefilter !== undefined || _denoising_quality !== undefined || _denoising_use_gpu !== undefined) {
const ot = types?.find((t) => t.id === id)
const existing = ot?.render_settings || {}
const existing = ot ? getOutputTypeInvocationOverrides(ot) : {}
const rs = { ...existing }
if (_width !== undefined) {
if (_width) rs.width = Number(_width); else delete rs.width
@@ -159,7 +209,7 @@ export default function OutputTypeTable() {
if (_denoising_use_gpu !== undefined) {
if (_denoising_use_gpu) rs.denoising_use_gpu = _denoising_use_gpu; else delete rs.denoising_use_gpu
}
rest.render_settings = rs
rest.invocation_overrides = rs
}
return updateOutputType(id, rest)
},
@@ -189,6 +239,7 @@ export default function OutputTypeTable() {
description: ot.description,
renderer: ot.renderer,
render_settings: ot.render_settings,
invocation_overrides: ot.invocation_overrides,
output_format: ot.output_format,
sort_order: ot.sort_order,
compatible_categories: ot.compatible_categories,
@@ -198,6 +249,8 @@ export default function OutputTypeTable() {
cycles_device: ot.cycles_device,
pricing_tier_id: ot.pricing_tier_id,
workflow_definition_id: ot.workflow_definition_id,
workflow_family: ot.workflow_family,
artifact_kind: ot.artifact_kind,
is_active: ot.is_active,
}),
onSuccess: () => {
@@ -229,34 +282,38 @@ export default function OutputTypeTable() {
// Getter helpers
const val = (field: keyof typeof form) => {
if (isEdit) {
const invocationOverrides = getOutputTypeInvocationOverrides(ot!)
if (field === 'name') return editDraft.name ?? ot!.name
if (field === 'renderer') return editDraft.renderer ?? ot!.renderer
if (field === 'output_format') return editDraft.output_format ?? ot!.output_format
if (field === 'is_animation') return editDraft.is_animation ?? ot!.is_animation
if (field === 'transparent_bg') return editDraft.transparent_bg ?? ot!.transparent_bg
if (field === 'workflow_family') return editDraft.workflow_family ?? ot!.workflow_family
if (field === 'artifact_kind') return editDraft.artifact_kind ?? ot!.artifact_kind
if (field === 'workflow_definition_id') return editDraft.workflow_definition_id ?? ot!.workflow_definition_id ?? ''
if (field === 'cycles_device') return editDraft.cycles_device ?? (ot!.cycles_device || '')
if (field === 'sort_order') return editDraft.sort_order ?? ot!.sort_order
if (field === 'pricing_tier_id') return editDraft.pricing_tier_id ?? ot!.pricing_tier_id ?? ''
if (field === 'material_override') return editDraft.material_override ?? ot!.material_override ?? ''
// render_settings fields (prefixed with _)
if (field === 'width') return (editDraft as any)._width ?? (ot!.render_settings?.width || '')
if (field === 'height') return (editDraft as any)._height ?? (ot!.render_settings?.height || '')
if (field === 'engine') return (editDraft as any)._engine ?? (ot!.render_settings?.engine || '')
if (field === 'samples') return (editDraft as any)._samples ?? (ot!.render_settings?.samples || '')
if (field === 'frame_count') return (editDraft as any)._frame_count ?? (ot!.render_settings?.frame_count || '')
if (field === 'fps') return (editDraft as any)._fps ?? (ot!.render_settings?.fps || '')
if (field === 'turntable_axis') return (editDraft as any)._turntable_axis ?? (ot!.render_settings?.turntable_axis || 'world_z')
if (field === 'width') return (editDraft as any)._width ?? (invocationOverrides.width || '')
if (field === 'height') return (editDraft as any)._height ?? (invocationOverrides.height || '')
if (field === 'engine') return (editDraft as any)._engine ?? (invocationOverrides.engine || '')
if (field === 'samples') return (editDraft as any)._samples ?? (invocationOverrides.samples || '')
if (field === 'frame_count') return (editDraft as any)._frame_count ?? (invocationOverrides.frame_count || '')
if (field === 'fps') return (editDraft as any)._fps ?? (invocationOverrides.fps || '')
if (field === 'turntable_axis') return (editDraft as any)._turntable_axis ?? (invocationOverrides.turntable_axis || 'world_z')
if (field === 'bg_color') {
return (editDraft as any)._bg_color !== undefined
? (editDraft as any)._bg_color as string
: (ot!.render_settings?.bg_color as string || '')
: (invocationOverrides.bg_color as string || '')
}
if (field === 'noise_threshold') return (editDraft as any)._noise_threshold ?? (ot!.render_settings?.noise_threshold as string || '')
if (field === 'denoiser') return (editDraft as any)._denoiser ?? (ot!.render_settings?.denoiser as string || '')
if (field === 'denoising_input_passes') return (editDraft as any)._denoising_input_passes ?? (ot!.render_settings?.denoising_input_passes as string || '')
if (field === 'denoising_prefilter') return (editDraft as any)._denoising_prefilter ?? (ot!.render_settings?.denoising_prefilter as string || '')
if (field === 'denoising_quality') return (editDraft as any)._denoising_quality ?? (ot!.render_settings?.denoising_quality as string || '')
if (field === 'denoising_use_gpu') return (editDraft as any)._denoising_use_gpu ?? (ot!.render_settings?.denoising_use_gpu as string || '')
if (field === 'noise_threshold') return (editDraft as any)._noise_threshold ?? (invocationOverrides.noise_threshold as string || '')
if (field === 'denoiser') return (editDraft as any)._denoiser ?? (invocationOverrides.denoiser as string || '')
if (field === 'denoising_input_passes') return (editDraft as any)._denoising_input_passes ?? (invocationOverrides.denoising_input_passes as string || '')
if (field === 'denoising_prefilter') return (editDraft as any)._denoising_prefilter ?? (invocationOverrides.denoising_prefilter as string || '')
if (field === 'denoising_quality') return (editDraft as any)._denoising_quality ?? (invocationOverrides.denoising_quality as string || '')
if (field === 'denoising_use_gpu') return (editDraft as any)._denoising_use_gpu ?? (invocationOverrides.denoising_use_gpu as string || '')
return (form as any)[field]
}
return (form as any)[field]
@@ -279,10 +336,15 @@ export default function OutputTypeTable() {
const currentRenderer = val('renderer') as string
const currentFormat = val('output_format') as string
const currentIsAnimation = val('is_animation') as boolean
const currentFamily = val('workflow_family') as OutputTypeWorkflowFamily
const currentArtifactKind = val('artifact_kind') as OutputTypeArtifactKind
const isBlender = showBlenderSettings(currentRenderer)
const showBg = showTransparentBg(currentRenderer, currentFormat)
const bgColor = val('bg_color') as string
const bgEnabled = bgColor !== ''
const workflowById = new Map((workflows ?? []).map(workflow => [workflow.id, workflow]))
const selectableWorkflows = (workflowsByFamily[currentFamily] ?? []).filter(workflow => getWorkflowFamily(workflow) !== 'mixed')
const artifactOptions = ARTIFACT_KINDS.filter(kind => listAllowedArtifactKindsForFamily(currentFamily).includes(kind.value))
const categoriesValue = isEdit
? (editDraft.compatible_categories ?? ot!.compatible_categories) || []
@@ -290,7 +352,7 @@ export default function OutputTypeTable() {
return (
<>
{/* Row 1: Name | Renderer | Format | Animation */}
{/* Row 1: Name | Family | Artifact | Workflow */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<label className="block text-xs font-medium text-content-muted mb-1">Name</label>
@@ -301,6 +363,66 @@ export default function OutputTypeTable() {
onChange={(e) => set('name', e.target.value)}
/>
</div>
<div>
<label className="block text-xs font-medium text-content-muted mb-1">Workflow Family</label>
<select
className="input-sm w-full"
value={currentFamily}
onChange={(e) => {
const nextFamily = e.target.value as OutputTypeWorkflowFamily
const nextArtifact = isArtifactKindAllowedForFamily(nextFamily, currentArtifactKind)
? currentArtifactKind
: inferArtifactKind(nextFamily, currentFormat, currentIsAnimation)
const currentWorkflowId = val('workflow_definition_id') as string
const currentWorkflow = workflowById.get(currentWorkflowId)
set('workflow_family', nextFamily)
set('artifact_kind', nextArtifact)
if (currentWorkflow && getWorkflowFamily(currentWorkflow) !== nextFamily) {
set('workflow_definition_id', '')
}
}}
>
{WORKFLOW_FAMILIES.map((family) => <option key={family.value} value={family.value}>{family.label}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-medium text-content-muted mb-1">Artifact Kind</label>
<select
className="input-sm w-full"
value={currentArtifactKind}
onChange={(e) => set('artifact_kind', e.target.value)}
>
{artifactOptions.map((kind) => <option key={kind.value} value={kind.value}>{kind.label}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-medium text-content-muted mb-1">Workflow</label>
<select
className="input-sm w-full"
value={val('workflow_definition_id') as string}
onChange={(e) => {
const nextWorkflowId = e.target.value
const nextWorkflow = workflowById.get(nextWorkflowId)
set('workflow_definition_id', nextWorkflowId)
const nextFamily = getWorkflowFamily(nextWorkflow as WorkflowDefinition)
if (nextFamily && nextFamily !== 'mixed') {
set('workflow_family', nextFamily)
if (!isArtifactKindAllowedForFamily(nextFamily, currentArtifactKind)) {
set('artifact_kind', inferArtifactKind(nextFamily, currentFormat, currentIsAnimation))
}
}
}}
>
<option value=""> Legacy fallback / none </option>
{selectableWorkflows.map((workflow) => (
<option key={workflow.id} value={workflow.id}>{workflow.name}</option>
))}
</select>
</div>
</div>
{/* Row 2: Renderer | Format | Animation | Pricing Tier */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
<div>
<label className="block text-xs font-medium text-content-muted mb-1">Renderer</label>
<select
@@ -316,7 +438,13 @@ export default function OutputTypeTable() {
<select
className="input-sm w-full"
value={currentFormat}
onChange={(e) => set('output_format', e.target.value)}
onChange={(e) => {
const nextFormat = e.target.value
set('output_format', nextFormat)
if (['still_image', 'thumbnail_image', 'turntable_video', 'model_export'].includes(currentArtifactKind)) {
set('artifact_kind', inferArtifactKind(currentFamily, nextFormat, currentIsAnimation))
}
}}
>
{FORMATS.map((f) => <option key={f}>{f}</option>)}
</select>
@@ -327,14 +455,35 @@ export default function OutputTypeTable() {
<input
type="checkbox"
checked={currentIsAnimation}
onChange={(e) => set('is_animation', e.target.checked)}
onChange={(e) => {
const checked = e.target.checked
set('is_animation', checked)
if (['still_image', 'thumbnail_image', 'turntable_video', 'model_export'].includes(currentArtifactKind)) {
set('artifact_kind', inferArtifactKind(currentFamily, currentFormat, checked))
}
}}
/>
<span className="text-sm text-content-secondary">Video output</span>
</label>
</div>
<div>
<label className="block text-xs font-medium text-content-muted mb-1">Pricing Tier</label>
<select
className="input-sm w-full"
value={val('pricing_tier_id') as string | number}
onChange={(e) => set('pricing_tier_id', e.target.value ? Number(e.target.value) : null)}
>
<option value="">Category default</option>
{pricingTiers?.map((t) => (
<option key={t.id} value={t.id}>
{t.category_key}/{t.quality_level} {t.price_per_item.toFixed(2)}
</option>
))}
</select>
</div>
</div>
{/* Row 2: Turntable | Background | Device | Engine */}
{/* Row 3: Turntable | Background | Device | Engine */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
<div>
<label className="block text-xs font-medium text-content-muted mb-1">Turntable</label>
@@ -452,7 +601,7 @@ export default function OutputTypeTable() {
</div>
</div>
{/* Row 3: Samples | Resolution | Pricing Tier | Workflow */}
{/* Row 4: Samples | Resolution | Categories | Material Override */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
<div>
<label className="block text-xs font-medium text-content-muted mb-1">Samples</label>
@@ -490,42 +639,6 @@ export default function OutputTypeTable() {
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-content-muted mb-1">Pricing Tier</label>
<select
className="input-sm w-full"
value={val('pricing_tier_id') as string | number}
onChange={(e) => set('pricing_tier_id', e.target.value ? Number(e.target.value) : null)}
>
<option value="">Category default</option>
{pricingTiers?.map((t) => (
<option key={t.id} value={t.id}>
{t.category_key}/{t.quality_level} {t.price_per_item.toFixed(2)}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-content-muted mb-1">Workflow</label>
{isEdit ? (
<select
className="input-sm w-full"
value={(editDraft.workflow_definition_id ?? ot!.workflow_definition_id) ?? ''}
onChange={(e) => set('workflow_definition_id', e.target.value || null)}
>
<option value=""> Legacy </option>
{workflows?.filter((w) => w.is_active).map((w) => (
<option key={w.id} value={w.id}>{w.name}</option>
))}
</select>
) : (
<span className="text-content-muted text-sm"> (set after creation)</span>
)}
</div>
</div>
{/* Row 4: Categories | Material Override | Sort Order | Active */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
<div>
<label className="block text-xs font-medium text-content-muted mb-1">Categories</label>
<CategoryMultiSelect
@@ -546,6 +659,10 @@ export default function OutputTypeTable() {
))}
</select>
</div>
</div>
{/* Row 5: Sort Order | Active */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
<div>
<label className="block text-xs font-medium text-content-muted mb-1">Sort Order</label>
<input
@@ -572,7 +689,7 @@ export default function OutputTypeTable() {
</div>
</div>
{/* Row 5: Denoising settings (only for Blender) */}
{/* Row 6: Denoising settings (only for Blender) */}
{isBlender && (
<div className="mt-4 pt-3 border-t border-border-light">
<label className="block text-xs font-medium text-content-muted mb-2">Denoising Settings (Blender)</label>
@@ -696,11 +813,26 @@ export default function OutputTypeTable() {
<td colSpan={18} className="px-4 py-4 text-center text-content-muted">Loading...</td>
</tr>
)}
{types?.map((ot) => (
{types?.map((ot) => {
const invocationProfile = getOutputTypeInvocationOverrides(ot)
return (
<React.Fragment key={ot.id}>
{/* Display row — always visible */}
<tr className={`border-b border-border-light hover:bg-surface-hover ${editingId === ot.id ? 'bg-surface-hover' : ''}`}>
<td className="px-4 py-2 font-medium">{ot.name}</td>
<td className="px-4 py-2">
<div className="flex flex-col gap-1">
<span className="font-medium">{ot.name}</span>
<div className="flex flex-wrap gap-1">
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-slate-100 text-slate-700">
{ot.workflow_family === 'cad_file' ? 'CAD Intake' : 'Order Rendering'}
</span>
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-50 text-amber-700">
{ARTIFACT_KINDS.find(kind => kind.value === ot.artifact_kind)?.label ?? ot.artifact_kind}
</span>
</div>
</div>
</td>
<td className="px-4 py-2 text-content-muted">{ot.renderer}</td>
<td className="px-4 py-2 text-content-muted">{ot.output_format}</td>
<td className="px-4 py-2">
@@ -712,10 +844,10 @@ export default function OutputTypeTable() {
{ot.is_animation ? (
<div className="flex flex-col gap-0.5">
<span className="text-xs text-content-secondary">
{(ot.render_settings?.frame_count as number) || 120}f / {(ot.render_settings?.fps as number) || 30}fps
{(invocationProfile.frame_count as number) || 120}f / {(invocationProfile.fps as number) || 30}fps
</span>
<span className="text-xs text-content-muted">
360° {({'world_z': 'World Z', 'world_x': 'World X', 'world_y': 'World Y'} as Record<string, string>)[ot.render_settings?.turntable_axis as string] ?? 'World Z'}
360° {({'world_z': 'World Z', 'world_x': 'World X', 'world_y': 'World Y'} as Record<string, string>)[invocationProfile.turntable_axis as string] ?? 'World Z'}
</span>
</div>
) : (
@@ -728,13 +860,13 @@ export default function OutputTypeTable() {
{ot.transparent_bg && (
<span className="text-xs px-1.5 py-0.5 rounded bg-sky-50 text-sky-700" title="Transparent background">alpha</span>
)}
{!!ot.render_settings?.bg_color && (
<div className="flex items-center gap-1" title={`BG color: ${ot.render_settings.bg_color}`}>
{!!invocationProfile.bg_color && (
<div className="flex items-center gap-1" title={`BG color: ${invocationProfile.bg_color}`}>
<span
className="inline-block w-4 h-4 rounded border border-border-default"
style={{ backgroundColor: ot.render_settings.bg_color as string }}
style={{ backgroundColor: invocationProfile.bg_color as string }}
/>
<span className="text-xs text-content-muted font-mono">{ot.render_settings.bg_color as string}</span>
<span className="text-xs text-content-muted font-mono">{invocationProfile.bg_color as string}</span>
</div>
)}
</div>
@@ -759,11 +891,11 @@ export default function OutputTypeTable() {
</td>
<td className="px-4 py-2">
{showBlenderSettings(ot.renderer) ? (
ot.render_settings?.engine ? (
invocationProfile.engine ? (
<span className={`text-xs px-1.5 py-0.5 rounded font-medium ${
ot.render_settings.engine === 'cycles' ? 'bg-status-success-bg text-status-success-text' : 'bg-status-info-bg text-status-info-text'
invocationProfile.engine === 'cycles' ? 'bg-status-success-bg text-status-success-text' : 'bg-status-info-bg text-status-info-text'
}`}>
{ot.render_settings.engine === 'cycles' ? 'Cycles' : 'EEVEE'}
{invocationProfile.engine === 'cycles' ? 'Cycles' : 'EEVEE'}
</span>
) : (
<span className="text-xs text-content-muted">default</span>
@@ -774,8 +906,8 @@ export default function OutputTypeTable() {
</td>
<td className="px-4 py-2">
{showBlenderSettings(ot.renderer) ? (
ot.render_settings?.samples ? (
<span className="text-xs font-medium text-content-secondary">{ot.render_settings.samples as number}</span>
invocationProfile.samples ? (
<span className="text-xs font-medium text-content-secondary">{invocationProfile.samples as number}</span>
) : (
<span className="text-xs text-content-muted">default</span>
)
@@ -786,18 +918,18 @@ export default function OutputTypeTable() {
<td className="px-4 py-2">
{showBlenderSettings(ot.renderer) ? (
<div className="flex flex-col gap-0.5">
{ot.render_settings?.denoiser ? (
{invocationProfile.denoiser ? (
<span className="text-xs px-1.5 py-0.5 rounded bg-indigo-50 text-indigo-700 font-medium">
{ot.render_settings.denoiser === 'OPTIX' ? 'OptiX' : 'OIDN'}
{invocationProfile.denoiser === 'OPTIX' ? 'OptiX' : 'OIDN'}
</span>
) : null}
{ot.render_settings?.noise_threshold ? (
<span className="text-xs text-content-muted">t={ot.render_settings.noise_threshold as string}</span>
{invocationProfile.noise_threshold ? (
<span className="text-xs text-content-muted">t={invocationProfile.noise_threshold as string}</span>
) : null}
{ot.render_settings?.denoising_prefilter ? (
<span className="text-xs text-content-muted">{ot.render_settings.denoising_prefilter as string}</span>
{invocationProfile.denoising_prefilter ? (
<span className="text-xs text-content-muted">{invocationProfile.denoising_prefilter as string}</span>
) : null}
{!ot.render_settings?.denoiser && !ot.render_settings?.noise_threshold && !ot.render_settings?.denoising_prefilter && (
{!invocationProfile.denoiser && !invocationProfile.noise_threshold && !invocationProfile.denoising_prefilter && (
<span className="text-xs text-content-muted">default</span>
)}
</div>
@@ -819,8 +951,8 @@ export default function OutputTypeTable() {
)}
</td>
<td className="px-4 py-2 text-content-muted text-xs">
{ot.render_settings?.width || ot.render_settings?.height
? `${ot.render_settings.width || '?'}x${ot.render_settings.height || '?'}`
{invocationProfile.width || invocationProfile.height
? `${invocationProfile.width || '?'}x${invocationProfile.height || '?'}`
: <span className="text-content-muted">default</span>}
</td>
<td className="px-4 py-2">
@@ -843,9 +975,14 @@ export default function OutputTypeTable() {
{(() => {
const wf = workflows?.find((w) => w.id === ot.workflow_definition_id)
return wf ? (
<span className="text-xs px-2 py-0.5 rounded-full bg-status-success-bg text-status-success-text font-medium">
{wf.name}
</span>
<div className="flex flex-col gap-1">
<span className="text-xs px-2 py-0.5 rounded-full bg-status-success-bg text-status-success-text font-medium">
{wf.name}
</span>
<span className="text-[10px] text-content-muted">
{getWorkflowFamily(wf) === 'cad_file' ? 'CAD Intake' : getWorkflowFamily(wf) === 'order_line' ? 'Order Rendering' : 'Mixed'}
</span>
</div>
) : (
<span className="text-xs px-2 py-0.5 rounded-full bg-surface-muted text-content-muted">
Legacy
@@ -927,7 +1064,7 @@ export default function OutputTypeTable() {
</tr>
)}
</React.Fragment>
))}
)})}
{/* Add new — expandable form */}
{showAdd && (