feat: make output types workflow-first contracts
This commit is contained in:
@@ -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")
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user