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