From 8c9648d5dc5e481d4dbb1c5f1490de38404fa7ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Wed, 8 Apr 2026 21:43:55 +0200 Subject: [PATCH] feat: make output types workflow-first contracts --- .../065_output_type_workflow_contracts.py | 57 +++ .../066_output_type_invocation_overrides.py | 72 ++++ backend/app/api/routers/output_types.py | 161 +++++++- .../rendering/output_type_contracts.py | 161 ++++++++ backend/app/models/output_type.py | 14 +- .../tests/domains/test_output_types_api.py | 268 ++++++++++++++ frontend/src/api/outputTypes.ts | 77 ++++ .../src/components/admin/OutputTypeTable.tsx | 349 ++++++++++++------ 8 files changed, 1049 insertions(+), 110 deletions(-) create mode 100644 backend/alembic/versions/065_output_type_workflow_contracts.py create mode 100644 backend/alembic/versions/066_output_type_invocation_overrides.py create mode 100644 backend/app/domains/rendering/output_type_contracts.py create mode 100644 backend/tests/domains/test_output_types_api.py diff --git a/backend/alembic/versions/065_output_type_workflow_contracts.py b/backend/alembic/versions/065_output_type_workflow_contracts.py new file mode 100644 index 0000000..ed6bacf --- /dev/null +++ b/backend/alembic/versions/065_output_type_workflow_contracts.py @@ -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") diff --git a/backend/alembic/versions/066_output_type_invocation_overrides.py b/backend/alembic/versions/066_output_type_invocation_overrides.py new file mode 100644 index 0000000..da0e542 --- /dev/null +++ b/backend/alembic/versions/066_output_type_invocation_overrides.py @@ -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") diff --git a/backend/app/api/routers/output_types.py b/backend/app/api/routers/output_types.py index 14e5dcc..a0524e1 100644 --- a/backend/app/api/routers/output_types.py +++ b/backend/app/api/routers/output_types.py @@ -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) diff --git a/backend/app/domains/rendering/output_type_contracts.py b/backend/app/domains/rendering/output_type_contracts.py new file mode 100644 index 0000000..52397b9 --- /dev/null +++ b/backend/app/domains/rendering/output_type_contracts.py @@ -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 diff --git a/backend/app/models/output_type.py b/backend/app/models/output_type.py index 0cda3d6..c8c26f0 100644 --- a/backend/app/models/output_type.py +++ b/backend/app/models/output_type.py @@ -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", +] diff --git a/backend/tests/domains/test_output_types_api.py b/backend/tests/domains/test_output_types_api.py new file mode 100644 index 0000000..f374bd9 --- /dev/null +++ b/backend/tests/domains/test_output_types_api.py @@ -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" diff --git a/frontend/src/api/outputTypes.ts b/frontend/src/api/outputTypes.ts index 4aeba98..ffd6b2c 100644 --- a/frontend/src/api/outputTypes.ts +++ b/frontend/src/api/outputTypes.ts @@ -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 + invocation_overrides: Record 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): P export async function deleteOutputType(id: string): Promise { 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 { + const normalized: Record = {} + 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 +} diff --git a/frontend/src/components/admin/OutputTypeTable.tsx b/frontend/src/components/admin/OutputTypeTable.tsx index 6c92e72..a037e12 100644 --- a/frontend/src/components/admin/OutputTypeTable.tsx +++ b/frontend/src/components/admin/OutputTypeTable.tsx @@ -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): Record { + const overrides: Record = {} + 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>((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 = {} - 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) }, 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 */}
@@ -301,6 +363,66 @@ export default function OutputTypeTable() { onChange={(e) => set('name', e.target.value)} />
+
+ + +
+
+ + +
+
+ + +
+
+ + {/* Row 2: Renderer | Format | Animation | Pricing Tier */} +
@@ -327,14 +455,35 @@ export default function OutputTypeTable() { 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)) + } + }} /> Video output
+
+ + +
- {/* Row 2: Turntable | Background | Device | Engine */} + {/* Row 3: Turntable | Background | Device | Engine */}
@@ -452,7 +601,7 @@ export default function OutputTypeTable() {
- {/* Row 3: Samples | Resolution | Pricing Tier | Workflow */} + {/* Row 4: Samples | Resolution | Categories | Material Override */}
@@ -490,42 +639,6 @@ export default function OutputTypeTable() { />
-
- - -
-
- - {isEdit ? ( - - ) : ( - — (set after creation) - )} -
- - - {/* Row 4: Categories | Material Override | Sort Order | Active */} -
+
+ + {/* Row 5: Sort Order | Active */} +
- {/* Row 5: Denoising settings (only for Blender) */} + {/* Row 6: Denoising settings (only for Blender) */} {isBlender && (
@@ -696,11 +813,26 @@ export default function OutputTypeTable() { Loading... )} - {types?.map((ot) => ( + {types?.map((ot) => { + const invocationProfile = getOutputTypeInvocationOverrides(ot) + + return ( {/* Display row — always visible */} - {ot.name} + +
+ {ot.name} +
+ + {ot.workflow_family === 'cad_file' ? 'CAD Intake' : 'Order Rendering'} + + + {ARTIFACT_KINDS.find(kind => kind.value === ot.artifact_kind)?.label ?? ot.artifact_kind} + +
+
+ {ot.renderer} {ot.output_format} @@ -712,10 +844,10 @@ export default function OutputTypeTable() { {ot.is_animation ? (
- {(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 - 360° {({'world_z': 'World Z', 'world_x': 'World X', 'world_y': 'World Y'} as Record)[ot.render_settings?.turntable_axis as string] ?? 'World Z'} + 360° {({'world_z': 'World Z', 'world_x': 'World X', 'world_y': 'World Y'} as Record)[invocationProfile.turntable_axis as string] ?? 'World Z'}
) : ( @@ -728,13 +860,13 @@ export default function OutputTypeTable() { {ot.transparent_bg && ( alpha )} - {!!ot.render_settings?.bg_color && ( -
+ {!!invocationProfile.bg_color && ( +
- {ot.render_settings.bg_color as string} + {invocationProfile.bg_color as string}
)}
@@ -759,11 +891,11 @@ export default function OutputTypeTable() { {showBlenderSettings(ot.renderer) ? ( - ot.render_settings?.engine ? ( + invocationProfile.engine ? ( - {ot.render_settings.engine === 'cycles' ? 'Cycles' : 'EEVEE'} + {invocationProfile.engine === 'cycles' ? 'Cycles' : 'EEVEE'} ) : ( default @@ -774,8 +906,8 @@ export default function OutputTypeTable() { {showBlenderSettings(ot.renderer) ? ( - ot.render_settings?.samples ? ( - {ot.render_settings.samples as number} + invocationProfile.samples ? ( + {invocationProfile.samples as number} ) : ( default ) @@ -786,18 +918,18 @@ export default function OutputTypeTable() { {showBlenderSettings(ot.renderer) ? (
- {ot.render_settings?.denoiser ? ( + {invocationProfile.denoiser ? ( - {ot.render_settings.denoiser === 'OPTIX' ? 'OptiX' : 'OIDN'} + {invocationProfile.denoiser === 'OPTIX' ? 'OptiX' : 'OIDN'} ) : null} - {ot.render_settings?.noise_threshold ? ( - t={ot.render_settings.noise_threshold as string} + {invocationProfile.noise_threshold ? ( + t={invocationProfile.noise_threshold as string} ) : null} - {ot.render_settings?.denoising_prefilter ? ( - {ot.render_settings.denoising_prefilter as string} + {invocationProfile.denoising_prefilter ? ( + {invocationProfile.denoising_prefilter as string} ) : null} - {!ot.render_settings?.denoiser && !ot.render_settings?.noise_threshold && !ot.render_settings?.denoising_prefilter && ( + {!invocationProfile.denoiser && !invocationProfile.noise_threshold && !invocationProfile.denoising_prefilter && ( default )}
@@ -819,8 +951,8 @@ export default function OutputTypeTable() { )} - {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 || '?'}` : default} @@ -843,9 +975,14 @@ export default function OutputTypeTable() { {(() => { const wf = workflows?.find((w) => w.id === ot.workflow_definition_id) return wf ? ( - - {wf.name} - +
+ + {wf.name} + + + {getWorkflowFamily(wf) === 'cad_file' ? 'CAD Intake' : getWorkflowFamily(wf) === 'order_line' ? 'Order Rendering' : 'Mixed'} + +
) : ( Legacy @@ -927,7 +1064,7 @@ export default function OutputTypeTable() { )}
- ))} + )})} {/* Add new — expandable form */} {showAdd && (