feat: make output types workflow-first contracts
This commit is contained in:
@@ -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