feat: make output types workflow-first contracts

This commit is contained in:
2026-04-08 21:43:55 +02:00
parent bd18cccb5e
commit 8c9648d5dc
8 changed files with 1049 additions and 110 deletions
+243 -106
View File
@@ -4,17 +4,34 @@ import { Pencil, Trash2, Plus, Check, X, ChevronDown, Copy } from 'lucide-react'
import { toast } from 'sonner'
import {
listOutputTypes, createOutputType, updateOutputType, deleteOutputType,
getOutputTypeInvocationOverrides,
inferArtifactKind,
isArtifactKindAllowedForFamily,
listAllowedArtifactKindsForFamily,
} from '../../api/outputTypes'
import type { OutputType } from '../../api/outputTypes'
import type { OutputType, OutputTypeArtifactKind, OutputTypeWorkflowFamily } from '../../api/outputTypes'
import { listMaterials } from '../../api/materials'
import type { Material } from '../../api/materials'
import { listPricingTiers } from '../../api/pricing'
import type { PricingTier } from '../../api/pricing'
import { getWorkflows } from '../../api/workflows'
import { getWorkflows, inferWorkflowFamily as inferWorkflowFamilyFromConfig } from '../../api/workflows'
import type { WorkflowDefinition } from '../../api/workflows'
const RENDERERS = ['blender', 'pillow']
const RENDERERS = ['blender']
const FORMATS = ['png', 'jpg', 'gltf', 'stl', 'mp4', 'webm']
const WORKFLOW_FAMILIES = [
{ value: 'order_line', label: 'Order Rendering' },
{ value: 'cad_file', label: 'CAD Intake' },
] as const
const ARTIFACT_KINDS = [
{ value: 'still_image', label: 'Still Image' },
{ value: 'turntable_video', label: 'Turntable Video' },
{ value: 'model_export', label: 'Model Export' },
{ value: 'thumbnail_image', label: 'Thumbnail Image' },
{ value: 'blend_asset', label: 'Blend Asset' },
{ value: 'package', label: 'Package' },
{ value: 'custom', label: 'Custom' },
] as const
const ALL_CATEGORIES = [
{ key: 'TRB', label: 'TRB' },
{ key: 'Kugellager', label: 'Kugellager' },
@@ -24,7 +41,30 @@ const ALL_CATEGORIES = [
{ key: 'Linear_schiene', label: 'Linear' },
{ key: 'Anschlagplatten', label: 'Anschlag' },
]
const EMPTY_FORM ={ name: '', description: '', renderer: 'threejs', output_format: 'png', sort_order: 0, compatible_categories: [] as string[], render_backend: 'auto', is_animation: false, transparent_bg: false, cycles_device: '' as string, pricing_tier_id: null as number | null, material_override: '' as string, width: '', height: '', engine: '', samples: '', frame_count: '', fps: '', turntable_axis: 'world_z', bg_color: '', noise_threshold: '', denoiser: '', denoising_input_passes: '', denoising_prefilter: '', denoising_quality: '', denoising_use_gpu: '' }
const EMPTY_FORM ={ name: '', description: '', renderer: 'blender', output_format: 'png', sort_order: 0, compatible_categories: [] as string[], render_backend: 'celery', is_animation: false, transparent_bg: false, workflow_family: 'order_line' as OutputTypeWorkflowFamily, artifact_kind: 'still_image' as OutputTypeArtifactKind, workflow_definition_id: '' as string, cycles_device: '' as string, pricing_tier_id: null as number | null, material_override: '' as string, width: '', height: '', engine: '', samples: '', frame_count: '', fps: '', turntable_axis: 'world_z', bg_color: '', noise_threshold: '', denoiser: '', denoising_input_passes: '', denoising_prefilter: '', denoising_quality: '', denoising_use_gpu: '' }
function getWorkflowFamily(workflow: WorkflowDefinition): 'cad_file' | 'order_line' | 'mixed' | null {
return workflow.family ?? inferWorkflowFamilyFromConfig(workflow.config)
}
function buildInvocationOverridesFromValues(values: Record<string, unknown>): Record<string, unknown> {
const overrides: Record<string, unknown> = {}
if (values.width) overrides.width = Number(values.width)
if (values.height) overrides.height = Number(values.height)
if (values.engine) overrides.engine = values.engine
if (values.samples) overrides.samples = Number(values.samples)
if (values.frame_count) overrides.frame_count = Number(values.frame_count)
if (values.fps) overrides.fps = Number(values.fps)
if (values.turntable_axis) overrides.turntable_axis = values.turntable_axis
if (values.bg_color) overrides.bg_color = values.bg_color
if (values.noise_threshold) overrides.noise_threshold = values.noise_threshold
if (values.denoiser) overrides.denoiser = values.denoiser
if (values.denoising_input_passes) overrides.denoising_input_passes = values.denoising_input_passes
if (values.denoising_prefilter) overrides.denoising_prefilter = values.denoising_prefilter
if (values.denoising_quality) overrides.denoising_quality = values.denoising_quality
if (values.denoising_use_gpu) overrides.denoising_use_gpu = values.denoising_use_gpu
return overrides
}
export default function OutputTypeTable() {
const qc = useQueryClient()
@@ -54,6 +94,14 @@ export default function OutputTypeTable() {
queryFn: getWorkflows,
})
const workflowsByFamily = (workflows ?? []).filter(w => w.is_active).reduce<Record<string, WorkflowDefinition[]>>((acc, workflow) => {
const family = getWorkflowFamily(workflow)
if (family === null) return acc
if (!acc[family]) acc[family] = []
acc[family].push(workflow)
return acc
}, {})
const updateWorkflowMut = useMutation({
mutationFn: ({ id, workflow_definition_id }: { id: string; workflow_definition_id: string | null }) =>
updateOutputType(id, { workflow_definition_id }),
@@ -67,23 +115,22 @@ export default function OutputTypeTable() {
const createMut = useMutation({
mutationFn: () => {
const rs: Record<string, unknown> = {}
if (form.width) rs.width = Number(form.width)
if (form.height) rs.height = Number(form.height)
if (form.engine) rs.engine = form.engine
if (form.samples) rs.samples = Number(form.samples)
if (form.is_animation) {
if (form.frame_count) rs.frame_count = Number(form.frame_count)
if (form.fps) rs.fps = Number(form.fps)
if (form.turntable_axis) rs.turntable_axis = form.turntable_axis
if (form.bg_color) rs.bg_color = form.bg_color
}
if (form.noise_threshold) rs.noise_threshold = form.noise_threshold
if (form.denoiser) rs.denoiser = form.denoiser
if (form.denoising_input_passes) rs.denoising_input_passes = form.denoising_input_passes
if (form.denoising_prefilter) rs.denoising_prefilter = form.denoising_prefilter
if (form.denoising_quality) rs.denoising_quality = form.denoising_quality
if (form.denoising_use_gpu) rs.denoising_use_gpu = form.denoising_use_gpu
const invocationOverrides = buildInvocationOverridesFromValues({
width: form.width,
height: form.height,
engine: form.engine,
samples: form.samples,
frame_count: form.is_animation ? form.frame_count : '',
fps: form.is_animation ? form.fps : '',
turntable_axis: form.is_animation ? form.turntable_axis : '',
bg_color: form.bg_color,
noise_threshold: form.noise_threshold,
denoiser: form.denoiser,
denoising_input_passes: form.denoising_input_passes,
denoising_prefilter: form.denoising_prefilter,
denoising_quality: form.denoising_quality,
denoising_use_gpu: form.denoising_use_gpu,
})
return createOutputType({
name: form.name.trim(),
description: form.description.trim() || undefined,
@@ -94,10 +141,13 @@ export default function OutputTypeTable() {
render_backend: form.render_backend,
is_animation: form.is_animation,
transparent_bg: form.transparent_bg,
workflow_family: form.workflow_family,
artifact_kind: form.artifact_kind,
invocation_overrides: invocationOverrides,
workflow_definition_id: form.workflow_definition_id || null,
cycles_device: form.cycles_device || null,
pricing_tier_id: form.pricing_tier_id,
material_override: form.material_override || null,
render_settings: Object.keys(rs).length > 0 ? rs : {},
} as Partial<OutputType>)
},
onSuccess: () => {
@@ -115,7 +165,7 @@ export default function OutputTypeTable() {
const { _width, _height, _engine, _samples, _frame_count, _fps, _turntable_axis, _bg_color, _noise_threshold, _denoiser, _denoising_input_passes, _denoising_prefilter, _denoising_quality, _denoising_use_gpu, ...rest } = data
if (_width !== undefined || _height !== undefined || _engine !== undefined || _samples !== undefined || _frame_count !== undefined || _fps !== undefined || _turntable_axis !== undefined || _bg_color !== undefined || _noise_threshold !== undefined || _denoiser !== undefined || _denoising_input_passes !== undefined || _denoising_prefilter !== undefined || _denoising_quality !== undefined || _denoising_use_gpu !== undefined) {
const ot = types?.find((t) => t.id === id)
const existing = ot?.render_settings || {}
const existing = ot ? getOutputTypeInvocationOverrides(ot) : {}
const rs = { ...existing }
if (_width !== undefined) {
if (_width) rs.width = Number(_width); else delete rs.width
@@ -159,7 +209,7 @@ export default function OutputTypeTable() {
if (_denoising_use_gpu !== undefined) {
if (_denoising_use_gpu) rs.denoising_use_gpu = _denoising_use_gpu; else delete rs.denoising_use_gpu
}
rest.render_settings = rs
rest.invocation_overrides = rs
}
return updateOutputType(id, rest)
},
@@ -189,6 +239,7 @@ export default function OutputTypeTable() {
description: ot.description,
renderer: ot.renderer,
render_settings: ot.render_settings,
invocation_overrides: ot.invocation_overrides,
output_format: ot.output_format,
sort_order: ot.sort_order,
compatible_categories: ot.compatible_categories,
@@ -198,6 +249,8 @@ export default function OutputTypeTable() {
cycles_device: ot.cycles_device,
pricing_tier_id: ot.pricing_tier_id,
workflow_definition_id: ot.workflow_definition_id,
workflow_family: ot.workflow_family,
artifact_kind: ot.artifact_kind,
is_active: ot.is_active,
}),
onSuccess: () => {
@@ -229,34 +282,38 @@ export default function OutputTypeTable() {
// Getter helpers
const val = (field: keyof typeof form) => {
if (isEdit) {
const invocationOverrides = getOutputTypeInvocationOverrides(ot!)
if (field === 'name') return editDraft.name ?? ot!.name
if (field === 'renderer') return editDraft.renderer ?? ot!.renderer
if (field === 'output_format') return editDraft.output_format ?? ot!.output_format
if (field === 'is_animation') return editDraft.is_animation ?? ot!.is_animation
if (field === 'transparent_bg') return editDraft.transparent_bg ?? ot!.transparent_bg
if (field === 'workflow_family') return editDraft.workflow_family ?? ot!.workflow_family
if (field === 'artifact_kind') return editDraft.artifact_kind ?? ot!.artifact_kind
if (field === 'workflow_definition_id') return editDraft.workflow_definition_id ?? ot!.workflow_definition_id ?? ''
if (field === 'cycles_device') return editDraft.cycles_device ?? (ot!.cycles_device || '')
if (field === 'sort_order') return editDraft.sort_order ?? ot!.sort_order
if (field === 'pricing_tier_id') return editDraft.pricing_tier_id ?? ot!.pricing_tier_id ?? ''
if (field === 'material_override') return editDraft.material_override ?? ot!.material_override ?? ''
// render_settings fields (prefixed with _)
if (field === 'width') return (editDraft as any)._width ?? (ot!.render_settings?.width || '')
if (field === 'height') return (editDraft as any)._height ?? (ot!.render_settings?.height || '')
if (field === 'engine') return (editDraft as any)._engine ?? (ot!.render_settings?.engine || '')
if (field === 'samples') return (editDraft as any)._samples ?? (ot!.render_settings?.samples || '')
if (field === 'frame_count') return (editDraft as any)._frame_count ?? (ot!.render_settings?.frame_count || '')
if (field === 'fps') return (editDraft as any)._fps ?? (ot!.render_settings?.fps || '')
if (field === 'turntable_axis') return (editDraft as any)._turntable_axis ?? (ot!.render_settings?.turntable_axis || 'world_z')
if (field === 'width') return (editDraft as any)._width ?? (invocationOverrides.width || '')
if (field === 'height') return (editDraft as any)._height ?? (invocationOverrides.height || '')
if (field === 'engine') return (editDraft as any)._engine ?? (invocationOverrides.engine || '')
if (field === 'samples') return (editDraft as any)._samples ?? (invocationOverrides.samples || '')
if (field === 'frame_count') return (editDraft as any)._frame_count ?? (invocationOverrides.frame_count || '')
if (field === 'fps') return (editDraft as any)._fps ?? (invocationOverrides.fps || '')
if (field === 'turntable_axis') return (editDraft as any)._turntable_axis ?? (invocationOverrides.turntable_axis || 'world_z')
if (field === 'bg_color') {
return (editDraft as any)._bg_color !== undefined
? (editDraft as any)._bg_color as string
: (ot!.render_settings?.bg_color as string || '')
: (invocationOverrides.bg_color as string || '')
}
if (field === 'noise_threshold') return (editDraft as any)._noise_threshold ?? (ot!.render_settings?.noise_threshold as string || '')
if (field === 'denoiser') return (editDraft as any)._denoiser ?? (ot!.render_settings?.denoiser as string || '')
if (field === 'denoising_input_passes') return (editDraft as any)._denoising_input_passes ?? (ot!.render_settings?.denoising_input_passes as string || '')
if (field === 'denoising_prefilter') return (editDraft as any)._denoising_prefilter ?? (ot!.render_settings?.denoising_prefilter as string || '')
if (field === 'denoising_quality') return (editDraft as any)._denoising_quality ?? (ot!.render_settings?.denoising_quality as string || '')
if (field === 'denoising_use_gpu') return (editDraft as any)._denoising_use_gpu ?? (ot!.render_settings?.denoising_use_gpu as string || '')
if (field === 'noise_threshold') return (editDraft as any)._noise_threshold ?? (invocationOverrides.noise_threshold as string || '')
if (field === 'denoiser') return (editDraft as any)._denoiser ?? (invocationOverrides.denoiser as string || '')
if (field === 'denoising_input_passes') return (editDraft as any)._denoising_input_passes ?? (invocationOverrides.denoising_input_passes as string || '')
if (field === 'denoising_prefilter') return (editDraft as any)._denoising_prefilter ?? (invocationOverrides.denoising_prefilter as string || '')
if (field === 'denoising_quality') return (editDraft as any)._denoising_quality ?? (invocationOverrides.denoising_quality as string || '')
if (field === 'denoising_use_gpu') return (editDraft as any)._denoising_use_gpu ?? (invocationOverrides.denoising_use_gpu as string || '')
return (form as any)[field]
}
return (form as any)[field]
@@ -279,10 +336,15 @@ export default function OutputTypeTable() {
const currentRenderer = val('renderer') as string
const currentFormat = val('output_format') as string
const currentIsAnimation = val('is_animation') as boolean
const currentFamily = val('workflow_family') as OutputTypeWorkflowFamily
const currentArtifactKind = val('artifact_kind') as OutputTypeArtifactKind
const isBlender = showBlenderSettings(currentRenderer)
const showBg = showTransparentBg(currentRenderer, currentFormat)
const bgColor = val('bg_color') as string
const bgEnabled = bgColor !== ''
const workflowById = new Map((workflows ?? []).map(workflow => [workflow.id, workflow]))
const selectableWorkflows = (workflowsByFamily[currentFamily] ?? []).filter(workflow => getWorkflowFamily(workflow) !== 'mixed')
const artifactOptions = ARTIFACT_KINDS.filter(kind => listAllowedArtifactKindsForFamily(currentFamily).includes(kind.value))
const categoriesValue = isEdit
? (editDraft.compatible_categories ?? ot!.compatible_categories) || []
@@ -290,7 +352,7 @@ export default function OutputTypeTable() {
return (
<>
{/* Row 1: Name | Renderer | Format | Animation */}
{/* Row 1: Name | Family | Artifact | Workflow */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<label className="block text-xs font-medium text-content-muted mb-1">Name</label>
@@ -301,6 +363,66 @@ export default function OutputTypeTable() {
onChange={(e) => set('name', e.target.value)}
/>
</div>
<div>
<label className="block text-xs font-medium text-content-muted mb-1">Workflow Family</label>
<select
className="input-sm w-full"
value={currentFamily}
onChange={(e) => {
const nextFamily = e.target.value as OutputTypeWorkflowFamily
const nextArtifact = isArtifactKindAllowedForFamily(nextFamily, currentArtifactKind)
? currentArtifactKind
: inferArtifactKind(nextFamily, currentFormat, currentIsAnimation)
const currentWorkflowId = val('workflow_definition_id') as string
const currentWorkflow = workflowById.get(currentWorkflowId)
set('workflow_family', nextFamily)
set('artifact_kind', nextArtifact)
if (currentWorkflow && getWorkflowFamily(currentWorkflow) !== nextFamily) {
set('workflow_definition_id', '')
}
}}
>
{WORKFLOW_FAMILIES.map((family) => <option key={family.value} value={family.value}>{family.label}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-medium text-content-muted mb-1">Artifact Kind</label>
<select
className="input-sm w-full"
value={currentArtifactKind}
onChange={(e) => set('artifact_kind', e.target.value)}
>
{artifactOptions.map((kind) => <option key={kind.value} value={kind.value}>{kind.label}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-medium text-content-muted mb-1">Workflow</label>
<select
className="input-sm w-full"
value={val('workflow_definition_id') as string}
onChange={(e) => {
const nextWorkflowId = e.target.value
const nextWorkflow = workflowById.get(nextWorkflowId)
set('workflow_definition_id', nextWorkflowId)
const nextFamily = getWorkflowFamily(nextWorkflow as WorkflowDefinition)
if (nextFamily && nextFamily !== 'mixed') {
set('workflow_family', nextFamily)
if (!isArtifactKindAllowedForFamily(nextFamily, currentArtifactKind)) {
set('artifact_kind', inferArtifactKind(nextFamily, currentFormat, currentIsAnimation))
}
}
}}
>
<option value=""> Legacy fallback / none </option>
{selectableWorkflows.map((workflow) => (
<option key={workflow.id} value={workflow.id}>{workflow.name}</option>
))}
</select>
</div>
</div>
{/* Row 2: Renderer | Format | Animation | Pricing Tier */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
<div>
<label className="block text-xs font-medium text-content-muted mb-1">Renderer</label>
<select
@@ -316,7 +438,13 @@ export default function OutputTypeTable() {
<select
className="input-sm w-full"
value={currentFormat}
onChange={(e) => set('output_format', e.target.value)}
onChange={(e) => {
const nextFormat = e.target.value
set('output_format', nextFormat)
if (['still_image', 'thumbnail_image', 'turntable_video', 'model_export'].includes(currentArtifactKind)) {
set('artifact_kind', inferArtifactKind(currentFamily, nextFormat, currentIsAnimation))
}
}}
>
{FORMATS.map((f) => <option key={f}>{f}</option>)}
</select>
@@ -327,14 +455,35 @@ export default function OutputTypeTable() {
<input
type="checkbox"
checked={currentIsAnimation}
onChange={(e) => set('is_animation', e.target.checked)}
onChange={(e) => {
const checked = e.target.checked
set('is_animation', checked)
if (['still_image', 'thumbnail_image', 'turntable_video', 'model_export'].includes(currentArtifactKind)) {
set('artifact_kind', inferArtifactKind(currentFamily, currentFormat, checked))
}
}}
/>
<span className="text-sm text-content-secondary">Video output</span>
</label>
</div>
<div>
<label className="block text-xs font-medium text-content-muted mb-1">Pricing Tier</label>
<select
className="input-sm w-full"
value={val('pricing_tier_id') as string | number}
onChange={(e) => set('pricing_tier_id', e.target.value ? Number(e.target.value) : null)}
>
<option value="">Category default</option>
{pricingTiers?.map((t) => (
<option key={t.id} value={t.id}>
{t.category_key}/{t.quality_level} {t.price_per_item.toFixed(2)}
</option>
))}
</select>
</div>
</div>
{/* Row 2: Turntable | Background | Device | Engine */}
{/* Row 3: Turntable | Background | Device | Engine */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
<div>
<label className="block text-xs font-medium text-content-muted mb-1">Turntable</label>
@@ -452,7 +601,7 @@ export default function OutputTypeTable() {
</div>
</div>
{/* Row 3: Samples | Resolution | Pricing Tier | Workflow */}
{/* Row 4: Samples | Resolution | Categories | Material Override */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
<div>
<label className="block text-xs font-medium text-content-muted mb-1">Samples</label>
@@ -490,42 +639,6 @@ export default function OutputTypeTable() {
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-content-muted mb-1">Pricing Tier</label>
<select
className="input-sm w-full"
value={val('pricing_tier_id') as string | number}
onChange={(e) => set('pricing_tier_id', e.target.value ? Number(e.target.value) : null)}
>
<option value="">Category default</option>
{pricingTiers?.map((t) => (
<option key={t.id} value={t.id}>
{t.category_key}/{t.quality_level} {t.price_per_item.toFixed(2)}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-content-muted mb-1">Workflow</label>
{isEdit ? (
<select
className="input-sm w-full"
value={(editDraft.workflow_definition_id ?? ot!.workflow_definition_id) ?? ''}
onChange={(e) => set('workflow_definition_id', e.target.value || null)}
>
<option value=""> Legacy </option>
{workflows?.filter((w) => w.is_active).map((w) => (
<option key={w.id} value={w.id}>{w.name}</option>
))}
</select>
) : (
<span className="text-content-muted text-sm"> (set after creation)</span>
)}
</div>
</div>
{/* Row 4: Categories | Material Override | Sort Order | Active */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
<div>
<label className="block text-xs font-medium text-content-muted mb-1">Categories</label>
<CategoryMultiSelect
@@ -546,6 +659,10 @@ export default function OutputTypeTable() {
))}
</select>
</div>
</div>
{/* Row 5: Sort Order | Active */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
<div>
<label className="block text-xs font-medium text-content-muted mb-1">Sort Order</label>
<input
@@ -572,7 +689,7 @@ export default function OutputTypeTable() {
</div>
</div>
{/* Row 5: Denoising settings (only for Blender) */}
{/* Row 6: Denoising settings (only for Blender) */}
{isBlender && (
<div className="mt-4 pt-3 border-t border-border-light">
<label className="block text-xs font-medium text-content-muted mb-2">Denoising Settings (Blender)</label>
@@ -696,11 +813,26 @@ export default function OutputTypeTable() {
<td colSpan={18} className="px-4 py-4 text-center text-content-muted">Loading...</td>
</tr>
)}
{types?.map((ot) => (
{types?.map((ot) => {
const invocationProfile = getOutputTypeInvocationOverrides(ot)
return (
<React.Fragment key={ot.id}>
{/* Display row — always visible */}
<tr className={`border-b border-border-light hover:bg-surface-hover ${editingId === ot.id ? 'bg-surface-hover' : ''}`}>
<td className="px-4 py-2 font-medium">{ot.name}</td>
<td className="px-4 py-2">
<div className="flex flex-col gap-1">
<span className="font-medium">{ot.name}</span>
<div className="flex flex-wrap gap-1">
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-slate-100 text-slate-700">
{ot.workflow_family === 'cad_file' ? 'CAD Intake' : 'Order Rendering'}
</span>
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-50 text-amber-700">
{ARTIFACT_KINDS.find(kind => kind.value === ot.artifact_kind)?.label ?? ot.artifact_kind}
</span>
</div>
</div>
</td>
<td className="px-4 py-2 text-content-muted">{ot.renderer}</td>
<td className="px-4 py-2 text-content-muted">{ot.output_format}</td>
<td className="px-4 py-2">
@@ -712,10 +844,10 @@ export default function OutputTypeTable() {
{ot.is_animation ? (
<div className="flex flex-col gap-0.5">
<span className="text-xs text-content-secondary">
{(ot.render_settings?.frame_count as number) || 120}f / {(ot.render_settings?.fps as number) || 30}fps
{(invocationProfile.frame_count as number) || 120}f / {(invocationProfile.fps as number) || 30}fps
</span>
<span className="text-xs text-content-muted">
360° {({'world_z': 'World Z', 'world_x': 'World X', 'world_y': 'World Y'} as Record<string, string>)[ot.render_settings?.turntable_axis as string] ?? 'World Z'}
360° {({'world_z': 'World Z', 'world_x': 'World X', 'world_y': 'World Y'} as Record<string, string>)[invocationProfile.turntable_axis as string] ?? 'World Z'}
</span>
</div>
) : (
@@ -728,13 +860,13 @@ export default function OutputTypeTable() {
{ot.transparent_bg && (
<span className="text-xs px-1.5 py-0.5 rounded bg-sky-50 text-sky-700" title="Transparent background">alpha</span>
)}
{!!ot.render_settings?.bg_color && (
<div className="flex items-center gap-1" title={`BG color: ${ot.render_settings.bg_color}`}>
{!!invocationProfile.bg_color && (
<div className="flex items-center gap-1" title={`BG color: ${invocationProfile.bg_color}`}>
<span
className="inline-block w-4 h-4 rounded border border-border-default"
style={{ backgroundColor: ot.render_settings.bg_color as string }}
style={{ backgroundColor: invocationProfile.bg_color as string }}
/>
<span className="text-xs text-content-muted font-mono">{ot.render_settings.bg_color as string}</span>
<span className="text-xs text-content-muted font-mono">{invocationProfile.bg_color as string}</span>
</div>
)}
</div>
@@ -759,11 +891,11 @@ export default function OutputTypeTable() {
</td>
<td className="px-4 py-2">
{showBlenderSettings(ot.renderer) ? (
ot.render_settings?.engine ? (
invocationProfile.engine ? (
<span className={`text-xs px-1.5 py-0.5 rounded font-medium ${
ot.render_settings.engine === 'cycles' ? 'bg-status-success-bg text-status-success-text' : 'bg-status-info-bg text-status-info-text'
invocationProfile.engine === 'cycles' ? 'bg-status-success-bg text-status-success-text' : 'bg-status-info-bg text-status-info-text'
}`}>
{ot.render_settings.engine === 'cycles' ? 'Cycles' : 'EEVEE'}
{invocationProfile.engine === 'cycles' ? 'Cycles' : 'EEVEE'}
</span>
) : (
<span className="text-xs text-content-muted">default</span>
@@ -774,8 +906,8 @@ export default function OutputTypeTable() {
</td>
<td className="px-4 py-2">
{showBlenderSettings(ot.renderer) ? (
ot.render_settings?.samples ? (
<span className="text-xs font-medium text-content-secondary">{ot.render_settings.samples as number}</span>
invocationProfile.samples ? (
<span className="text-xs font-medium text-content-secondary">{invocationProfile.samples as number}</span>
) : (
<span className="text-xs text-content-muted">default</span>
)
@@ -786,18 +918,18 @@ export default function OutputTypeTable() {
<td className="px-4 py-2">
{showBlenderSettings(ot.renderer) ? (
<div className="flex flex-col gap-0.5">
{ot.render_settings?.denoiser ? (
{invocationProfile.denoiser ? (
<span className="text-xs px-1.5 py-0.5 rounded bg-indigo-50 text-indigo-700 font-medium">
{ot.render_settings.denoiser === 'OPTIX' ? 'OptiX' : 'OIDN'}
{invocationProfile.denoiser === 'OPTIX' ? 'OptiX' : 'OIDN'}
</span>
) : null}
{ot.render_settings?.noise_threshold ? (
<span className="text-xs text-content-muted">t={ot.render_settings.noise_threshold as string}</span>
{invocationProfile.noise_threshold ? (
<span className="text-xs text-content-muted">t={invocationProfile.noise_threshold as string}</span>
) : null}
{ot.render_settings?.denoising_prefilter ? (
<span className="text-xs text-content-muted">{ot.render_settings.denoising_prefilter as string}</span>
{invocationProfile.denoising_prefilter ? (
<span className="text-xs text-content-muted">{invocationProfile.denoising_prefilter as string}</span>
) : null}
{!ot.render_settings?.denoiser && !ot.render_settings?.noise_threshold && !ot.render_settings?.denoising_prefilter && (
{!invocationProfile.denoiser && !invocationProfile.noise_threshold && !invocationProfile.denoising_prefilter && (
<span className="text-xs text-content-muted">default</span>
)}
</div>
@@ -819,8 +951,8 @@ export default function OutputTypeTable() {
)}
</td>
<td className="px-4 py-2 text-content-muted text-xs">
{ot.render_settings?.width || ot.render_settings?.height
? `${ot.render_settings.width || '?'}x${ot.render_settings.height || '?'}`
{invocationProfile.width || invocationProfile.height
? `${invocationProfile.width || '?'}x${invocationProfile.height || '?'}`
: <span className="text-content-muted">default</span>}
</td>
<td className="px-4 py-2">
@@ -843,9 +975,14 @@ export default function OutputTypeTable() {
{(() => {
const wf = workflows?.find((w) => w.id === ot.workflow_definition_id)
return wf ? (
<span className="text-xs px-2 py-0.5 rounded-full bg-status-success-bg text-status-success-text font-medium">
{wf.name}
</span>
<div className="flex flex-col gap-1">
<span className="text-xs px-2 py-0.5 rounded-full bg-status-success-bg text-status-success-text font-medium">
{wf.name}
</span>
<span className="text-[10px] text-content-muted">
{getWorkflowFamily(wf) === 'cad_file' ? 'CAD Intake' : getWorkflowFamily(wf) === 'order_line' ? 'Order Rendering' : 'Mixed'}
</span>
</div>
) : (
<span className="text-xs px-2 py-0.5 rounded-full bg-surface-muted text-content-muted">
Legacy
@@ -927,7 +1064,7 @@ export default function OutputTypeTable() {
</tr>
)}
</React.Fragment>
))}
)})}
{/* Add new — expandable form */}
{showAdd && (