- Save button disabled for non-admins (canSave prop threaded through WorkflowEditor → WorkflowCanvas → WorkflowCanvasToolbar); tooltip explains why when hovered - Optimistic concurrency: migration 072 adds updated_at to workflow_definitions; PUT /workflows/:id returns 409 when client sends a stale updated_at; frontend shows a specific reload-prompt toast - _legacy_dispatch now routes through dispatch_order_line_render instead of calling render_order_line_task directly, so cancelled/rejected order lines are skipped before queueing Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
982 lines
31 KiB
TypeScript
982 lines
31 KiB
TypeScript
import api from './client'
|
|
import type { OutputTypeArtifactKind, OutputTypeWorkflowRolloutMode } from './outputTypes'
|
|
|
|
export type WorkflowPresetType = 'still' | 'still_graph' | 'turntable' | 'multi_angle' | 'still_with_exports' | 'custom'
|
|
export type WorkflowExecutionMode = 'legacy' | 'graph' | 'shadow'
|
|
export type WorkflowStarterFamily = 'cad_file' | 'order_line'
|
|
export type WorkflowBlueprintType =
|
|
| 'cad_intake'
|
|
| 'order_rendering'
|
|
| 'still_graph_reference'
|
|
| 'still_graph_alpha_reference'
|
|
| 'still_graph_blend_reference'
|
|
export type WorkflowCanonicalBlueprintType = WorkflowBlueprintType | 'starter_cad_intake' | 'starter_order_rendering'
|
|
|
|
export interface WorkflowRolloutLatestRun {
|
|
workflow_run_id: string
|
|
execution_mode: WorkflowExecutionMode
|
|
status: string
|
|
created_at: string
|
|
completed_at: string | null
|
|
}
|
|
|
|
export interface WorkflowRolloutLinkedOutputType {
|
|
id: string
|
|
name: string
|
|
is_active: boolean
|
|
artifact_kind: OutputTypeArtifactKind
|
|
workflow_rollout_mode: OutputTypeWorkflowRolloutMode
|
|
}
|
|
|
|
export interface WorkflowRolloutSummary {
|
|
linked_output_type_count: number
|
|
active_output_type_count: number
|
|
linked_output_type_names: string[]
|
|
linked_output_types: WorkflowRolloutLinkedOutputType[]
|
|
rollout_modes: ('legacy_only' | 'shadow' | 'graph' | string)[]
|
|
has_blocking_contracts: boolean
|
|
blocking_reasons: string[]
|
|
latest_run: WorkflowRolloutLatestRun | null
|
|
latest_shadow_run: WorkflowRolloutLatestRun | null
|
|
latest_rollout_gate_verdict: 'pass' | 'warn' | 'fail' | null
|
|
latest_rollout_ready: boolean | null
|
|
latest_rollout_status: 'ready_for_rollout' | 'hold_legacy_authoritative' | string | null
|
|
latest_rollout_reasons: string[]
|
|
}
|
|
|
|
export interface WorkflowDefinition {
|
|
id: string
|
|
name: string
|
|
output_type_id: string | null
|
|
config: WorkflowConfig
|
|
family: WorkflowNodeFamily | 'mixed' | null
|
|
supported_artifact_kinds?: OutputTypeArtifactKind[]
|
|
rollout_summary: WorkflowRolloutSummary
|
|
is_active: boolean
|
|
created_at: string
|
|
updated_at: string
|
|
}
|
|
|
|
export interface WorkflowConfig {
|
|
version: number
|
|
nodes: WorkflowNode[]
|
|
edges: WorkflowEdge[]
|
|
ui?: WorkflowUi
|
|
}
|
|
|
|
export interface WorkflowParams {
|
|
[key: string]: unknown
|
|
use_custom_render_settings?: boolean
|
|
render_engine?: 'cycles' | 'eevee'
|
|
samples?: number
|
|
resolution?: [number, number]
|
|
fps?: number
|
|
duration_s?: number
|
|
angles?: number[]
|
|
rotation_z?: number
|
|
width?: number
|
|
height?: number
|
|
}
|
|
|
|
export interface WorkflowNode {
|
|
id: string
|
|
step: string
|
|
params: WorkflowParams
|
|
ui?: WorkflowNodeUi
|
|
}
|
|
|
|
export interface WorkflowNodeUi {
|
|
type?: string
|
|
position?: { x: number; y: number }
|
|
label?: string
|
|
}
|
|
|
|
export interface WorkflowEdge {
|
|
from: string
|
|
to: string
|
|
}
|
|
|
|
export interface WorkflowUi {
|
|
preset?: WorkflowPresetType
|
|
execution_mode?: WorkflowExecutionMode
|
|
family?: WorkflowNodeFamily | 'mixed'
|
|
blueprint?: string
|
|
}
|
|
|
|
export interface WorkflowCreate {
|
|
name: string
|
|
output_type_id?: string | null
|
|
config: WorkflowConfig
|
|
is_active?: boolean
|
|
}
|
|
|
|
export interface WorkflowRun {
|
|
id: string
|
|
workflow_def_id: string | null
|
|
order_line_id: string | null
|
|
celery_task_id: string | null
|
|
execution_mode: WorkflowExecutionMode
|
|
status: 'pending' | 'running' | 'completed' | 'failed'
|
|
started_at: string | null
|
|
completed_at: string | null
|
|
error_message: string | null
|
|
created_at: string
|
|
node_results: WorkflowNodeResult[]
|
|
}
|
|
|
|
export interface WorkflowNodeResult {
|
|
id: string
|
|
node_name: string
|
|
status: string
|
|
output: Record<string, unknown> | null
|
|
log: string | null
|
|
duration_s: number | null
|
|
created_at: string
|
|
}
|
|
|
|
export interface WorkflowDispatchResponse {
|
|
workflow_run: WorkflowRun
|
|
context_id: string
|
|
execution_mode: WorkflowExecutionMode
|
|
dispatched: number
|
|
task_ids: string[]
|
|
}
|
|
|
|
export interface WorkflowPreflightIssue {
|
|
severity: 'error' | 'warning' | 'info'
|
|
code: string
|
|
message: string
|
|
node_id: string | null
|
|
step: string | null
|
|
}
|
|
|
|
export interface WorkflowPreflightNode {
|
|
node_id: string
|
|
step: string
|
|
label: string | null
|
|
execution_kind: WorkflowNodeExecutionKind
|
|
supported: boolean
|
|
status: 'ready' | 'warning' | 'error' | 'unsupported'
|
|
issues: WorkflowPreflightIssue[]
|
|
}
|
|
|
|
export interface WorkflowPreflightResponse {
|
|
workflow_id: string | null
|
|
context_id: string
|
|
context_kind: 'order_line' | 'cad_file' | null
|
|
expected_context_kind: 'order_line' | 'cad_file'
|
|
execution_mode: WorkflowExecutionMode
|
|
graph_dispatch_allowed: boolean
|
|
summary: string
|
|
resolved_order_line_id: string | null
|
|
resolved_cad_file_id: string | null
|
|
unsupported_node_ids: string[]
|
|
issues: WorkflowPreflightIssue[]
|
|
nodes: WorkflowPreflightNode[]
|
|
}
|
|
|
|
export interface WorkflowOrderLineContextOption {
|
|
value: string
|
|
label: string
|
|
meta: string
|
|
is_renderable: boolean
|
|
renderability_reason: string | null
|
|
}
|
|
|
|
export interface WorkflowOrderLineContextGroup {
|
|
order_id: string
|
|
order_label: string
|
|
options: WorkflowOrderLineContextOption[]
|
|
}
|
|
|
|
export interface WorkflowDraftPreflightRequest {
|
|
workflow_id?: string | null
|
|
context_id: string
|
|
config: WorkflowConfig
|
|
}
|
|
|
|
export interface WorkflowDraftDispatchRequest {
|
|
workflow_id?: string | null
|
|
context_id: string
|
|
config: WorkflowConfig
|
|
}
|
|
|
|
export interface WorkflowComparisonArtifact {
|
|
path: string | null
|
|
storage_key: string | null
|
|
exists: boolean
|
|
file_size_bytes: number | null
|
|
sha256: string | null
|
|
mime_type: string | null
|
|
image_width: number | null
|
|
image_height: number | null
|
|
}
|
|
|
|
export interface WorkflowRunComparison {
|
|
workflow_run_id: string
|
|
workflow_def_id: string | null
|
|
order_line_id: string | null
|
|
execution_mode: WorkflowExecutionMode
|
|
status: string
|
|
summary: string
|
|
rollout_gate_verdict: 'pass' | 'warn' | 'fail'
|
|
workflow_rollout_ready: boolean
|
|
workflow_rollout_status: 'ready_for_rollout' | 'hold_legacy_authoritative'
|
|
rollout_reasons: string[]
|
|
rollout_thresholds: Record<string, number>
|
|
authoritative_output: WorkflowComparisonArtifact
|
|
observer_output: WorkflowComparisonArtifact
|
|
exact_match: boolean | null
|
|
dimensions_match: boolean | null
|
|
mean_pixel_delta: number | null
|
|
}
|
|
|
|
export const getWorkflows = (): Promise<WorkflowDefinition[]> =>
|
|
api.get('/workflows').then(r => r.data.map(normalizeWorkflowDefinition))
|
|
|
|
export const getWorkflow = (id: string): Promise<WorkflowDefinition> =>
|
|
api.get(`/workflows/${id}`).then(r => normalizeWorkflowDefinition(r.data))
|
|
|
|
export const createWorkflow = (data: WorkflowCreate): Promise<WorkflowDefinition> =>
|
|
api.post('/workflows', data).then(r => normalizeWorkflowDefinition(r.data))
|
|
|
|
export interface WorkflowUpdatePayload extends Partial<WorkflowCreate> {
|
|
updated_at?: string
|
|
}
|
|
|
|
export const updateWorkflow = (id: string, data: WorkflowUpdatePayload): Promise<WorkflowDefinition> =>
|
|
api.put(`/workflows/${id}`, data).then(r => normalizeWorkflowDefinition(r.data))
|
|
|
|
export const deleteWorkflow = (id: string): Promise<void> =>
|
|
api.delete(`/workflows/${id}`).then(() => undefined)
|
|
|
|
export const getWorkflowRuns = (workflowId: string): Promise<WorkflowRun[]> =>
|
|
api.get(`/workflows/${workflowId}/runs`).then(r => r.data)
|
|
|
|
export const dispatchWorkflow = (
|
|
workflowId: string,
|
|
contextId: string,
|
|
): Promise<WorkflowDispatchResponse> =>
|
|
api.post(`/workflows/${workflowId}/dispatch`, undefined, { params: { context_id: contextId } }).then(r => r.data)
|
|
|
|
export const dispatchWorkflowDraft = (
|
|
data: WorkflowDraftDispatchRequest,
|
|
): Promise<WorkflowDispatchResponse> =>
|
|
api.post('/workflows/dispatch', data).then(r => r.data)
|
|
|
|
export const preflightWorkflow = (
|
|
workflowId: string,
|
|
contextId: string,
|
|
): Promise<WorkflowPreflightResponse> =>
|
|
api.get(`/workflows/${workflowId}/preflight`, { params: { context_id: contextId } }).then(r => r.data)
|
|
|
|
export const preflightWorkflowDraft = (
|
|
data: WorkflowDraftPreflightRequest,
|
|
): Promise<WorkflowPreflightResponse> =>
|
|
api.post('/workflows/preflight', data).then(r => r.data)
|
|
|
|
export const getWorkflowOrderLineContexts = (limit = 50): Promise<WorkflowOrderLineContextGroup[]> =>
|
|
api.get('/workflows/contexts/order-lines', { params: { limit } }).then(r => r.data)
|
|
|
|
export const getWorkflowRunComparison = (runId: string): Promise<WorkflowRunComparison> =>
|
|
api.get(`/workflows/runs/${runId}/comparison`).then(r => r.data)
|
|
|
|
// ─── Node Definitions / Pipeline Steps ───────────────────────────────────────
|
|
|
|
export type StepCategory = 'input' | 'processing' | 'rendering' | 'output'
|
|
export type WorkflowNodeFieldType = 'number' | 'select' | 'boolean' | 'text'
|
|
export type WorkflowNodeExecutionKind = 'native' | 'bridge'
|
|
|
|
export interface WorkflowNodeFieldOption {
|
|
value: string | number | boolean
|
|
label: string
|
|
}
|
|
|
|
export interface WorkflowNodeFieldDefinition {
|
|
key: string
|
|
label: string
|
|
type: WorkflowNodeFieldType
|
|
description: string
|
|
section: string
|
|
default: unknown
|
|
min: number | null
|
|
max: number | null
|
|
step: number | null
|
|
unit: string | null
|
|
options: WorkflowNodeFieldOption[]
|
|
allow_blank?: boolean
|
|
max_length?: number | null
|
|
text_format?: string
|
|
}
|
|
|
|
export type WorkflowNodeFamily = 'cad_file' | 'order_line' | 'shared'
|
|
|
|
export interface WorkflowNodeDefinition {
|
|
step: string
|
|
label: string
|
|
family: WorkflowNodeFamily
|
|
module_key: string
|
|
category: StepCategory
|
|
description: string
|
|
node_type: string
|
|
icon: string
|
|
defaults: WorkflowParams
|
|
fields: WorkflowNodeFieldDefinition[]
|
|
execution_kind: WorkflowNodeExecutionKind
|
|
legacy_compatible: boolean
|
|
input_contract: Record<string, unknown>
|
|
output_contract: Record<string, unknown>
|
|
artifact_roles_produced: string[]
|
|
artifact_roles_consumed: string[]
|
|
legacy_source: string | null
|
|
}
|
|
|
|
export interface WorkflowNodeDefinitionsResponse {
|
|
definitions: WorkflowNodeDefinition[]
|
|
}
|
|
|
|
export interface PipelineStep {
|
|
name: string
|
|
label: string
|
|
category: StepCategory
|
|
description: string
|
|
}
|
|
|
|
export interface PipelineStepsResponse {
|
|
steps: PipelineStep[]
|
|
}
|
|
|
|
export const getNodeDefinitions = (): Promise<WorkflowNodeDefinitionsResponse> =>
|
|
api.get('/workflows/node-definitions').then(r => r.data)
|
|
|
|
export const getPipelineSteps = (): Promise<PipelineStepsResponse> =>
|
|
api.get('/workflows/pipeline-steps').then(r => r.data)
|
|
|
|
function normalizeRenderParams(params: WorkflowParams = {}): WorkflowParams {
|
|
const normalized = { ...params }
|
|
const resolution = Array.isArray(normalized.resolution) ? normalized.resolution : undefined
|
|
if (resolution && resolution.length === 2) {
|
|
normalized.width = Number(resolution[0])
|
|
normalized.height = Number(resolution[1])
|
|
delete normalized.resolution
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
function buildWorkflowNode(
|
|
id: string,
|
|
step: string,
|
|
x: number,
|
|
y: number,
|
|
options: {
|
|
label: string
|
|
type?: string
|
|
params?: WorkflowParams
|
|
},
|
|
): WorkflowNode {
|
|
return {
|
|
id,
|
|
step,
|
|
params: { ...(options.params ?? {}) },
|
|
ui: {
|
|
type: options.type,
|
|
label: options.label,
|
|
position: { x, y },
|
|
},
|
|
}
|
|
}
|
|
|
|
function extractRenderParamsFromNodes(nodes: WorkflowNode[], step: string): WorkflowParams {
|
|
const match = nodes.find(node => node.step === step)
|
|
return normalizeRenderParams(match?.params ?? {})
|
|
}
|
|
|
|
function buildOrderLineStillGraphNodes(
|
|
renderParams: WorkflowParams,
|
|
options: {
|
|
transparentBg?: boolean
|
|
includeBlendExport?: boolean
|
|
} = {},
|
|
): { nodes: WorkflowNode[]; edges: WorkflowEdge[] } {
|
|
const resolvedRenderParams = {
|
|
use_custom_render_settings: false,
|
|
...renderParams,
|
|
...(options.transparentBg !== undefined ? { transparent_bg: options.transparentBg } : {}),
|
|
}
|
|
|
|
const nodes: WorkflowNode[] = [
|
|
buildWorkflowNode('setup', 'order_line_setup', 0, 160, { label: 'Order Line Setup' }),
|
|
buildWorkflowNode('template', 'resolve_template', 220, 160, { label: 'Resolve Template' }),
|
|
buildWorkflowNode('populate_materials', 'auto_populate_materials', 220, 320, {
|
|
label: 'Auto Populate Materials',
|
|
type: 'processNode',
|
|
}),
|
|
buildWorkflowNode('bbox', 'glb_bbox', 220, 40, {
|
|
label: 'Compute Bounding Box',
|
|
type: 'processNode',
|
|
}),
|
|
buildWorkflowNode('resolve_materials', 'material_map_resolve', 440, 200, {
|
|
label: 'Resolve Material Map',
|
|
type: 'processNode',
|
|
}),
|
|
buildWorkflowNode('render', 'blender_still', 680, 160, {
|
|
label: 'Still Render',
|
|
type: 'renderNode',
|
|
params: resolvedRenderParams,
|
|
}),
|
|
buildWorkflowNode('output', 'output_save', 920, 120, {
|
|
label: 'Save Output',
|
|
type: 'outputNode',
|
|
}),
|
|
buildWorkflowNode('notify', 'notify', 920, 220, {
|
|
label: 'Notify Result',
|
|
type: 'outputNode',
|
|
}),
|
|
]
|
|
|
|
const edges: WorkflowEdge[] = [
|
|
{ from: 'setup', to: 'template' },
|
|
{ from: 'setup', to: 'populate_materials' },
|
|
{ from: 'setup', to: 'bbox' },
|
|
{ from: 'template', to: 'resolve_materials' },
|
|
{ from: 'populate_materials', to: 'resolve_materials' },
|
|
{ from: 'resolve_materials', to: 'render' },
|
|
{ from: 'bbox', to: 'render' },
|
|
{ from: 'template', to: 'render' },
|
|
{ from: 'render', to: 'output' },
|
|
{ from: 'render', to: 'notify' },
|
|
]
|
|
|
|
if (options.includeBlendExport) {
|
|
nodes.push(
|
|
buildWorkflowNode('blend_export', 'export_blend', 920, 360, {
|
|
label: 'Export Blend',
|
|
type: 'outputNode',
|
|
}),
|
|
buildWorkflowNode('save_blend', 'output_save', 1160, 320, {
|
|
label: 'Save Blend Output',
|
|
type: 'outputNode',
|
|
params: { expected_artifact_role: 'blend_export' },
|
|
}),
|
|
buildWorkflowNode('notify_blend', 'notify', 1160, 420, {
|
|
label: 'Notify Blend Export',
|
|
type: 'outputNode',
|
|
}),
|
|
)
|
|
edges.push(
|
|
{ from: 'setup', to: 'blend_export' },
|
|
{ from: 'template', to: 'blend_export' },
|
|
{ from: 'blend_export', to: 'save_blend' },
|
|
{ from: 'blend_export', to: 'notify_blend' },
|
|
)
|
|
}
|
|
|
|
return {
|
|
nodes,
|
|
edges,
|
|
}
|
|
}
|
|
|
|
function buildPresetWorkflowConfigInternal(type: WorkflowPresetType, params: WorkflowParams = {}): WorkflowConfig {
|
|
const renderParams = normalizeRenderParams(params)
|
|
|
|
if (type === 'still') {
|
|
return {
|
|
version: 1,
|
|
ui: { preset: type, execution_mode: 'legacy', family: 'order_line' },
|
|
nodes: [
|
|
buildWorkflowNode('setup', 'order_line_setup', 0, 100, { label: 'Order Line Setup' }),
|
|
buildWorkflowNode('template', 'resolve_template', 220, 100, { label: 'Resolve Template' }),
|
|
buildWorkflowNode('render', 'blender_still', 440, 100, {
|
|
label: 'Still Render',
|
|
type: 'renderNode',
|
|
params: renderParams,
|
|
}),
|
|
buildWorkflowNode('output', 'output_save', 660, 100, {
|
|
label: 'Save Output',
|
|
type: 'outputNode',
|
|
}),
|
|
],
|
|
edges: [
|
|
{ from: 'setup', to: 'template' },
|
|
{ from: 'template', to: 'render' },
|
|
{ from: 'render', to: 'output' },
|
|
],
|
|
}
|
|
}
|
|
|
|
if (type === 'still_graph') {
|
|
const { nodes, edges } = buildOrderLineStillGraphNodes(renderParams)
|
|
return {
|
|
version: 1,
|
|
ui: { preset: type, execution_mode: 'graph', family: 'order_line' },
|
|
nodes,
|
|
edges,
|
|
}
|
|
}
|
|
|
|
if (type === 'turntable') {
|
|
return {
|
|
version: 1,
|
|
ui: { preset: type, execution_mode: 'legacy', family: 'order_line' },
|
|
nodes: [
|
|
buildWorkflowNode('setup', 'order_line_setup', 0, 100, { label: 'Order Line Setup' }),
|
|
buildWorkflowNode('template', 'resolve_template', 220, 100, { label: 'Resolve Template' }),
|
|
buildWorkflowNode('turntable', 'blender_turntable', 440, 100, {
|
|
label: 'Turntable Render',
|
|
type: 'renderFramesNode',
|
|
params: renderParams,
|
|
}),
|
|
buildWorkflowNode('output', 'output_save', 660, 100, {
|
|
label: 'Save Output',
|
|
type: 'outputNode',
|
|
}),
|
|
],
|
|
edges: [
|
|
{ from: 'setup', to: 'template' },
|
|
{ from: 'template', to: 'turntable' },
|
|
{ from: 'turntable', to: 'output' },
|
|
],
|
|
}
|
|
}
|
|
|
|
if (type === 'multi_angle') {
|
|
const angles = (params.angles ?? [0, 45, 90]).map(Number)
|
|
const sharedParams = { ...renderParams }
|
|
delete sharedParams.angles
|
|
return {
|
|
version: 1,
|
|
ui: { preset: type, execution_mode: 'legacy', family: 'order_line' },
|
|
nodes: [
|
|
buildWorkflowNode('setup', 'order_line_setup', 0, 195, { label: 'Order Line Setup' }),
|
|
buildWorkflowNode('template', 'resolve_template', 220, 195, { label: 'Resolve Template' }),
|
|
...angles.map((angle, index) =>
|
|
buildWorkflowNode(`render_${index}`, 'blender_still', 440, index * 130, {
|
|
label: `Render ${angle}°`,
|
|
type: 'renderNode',
|
|
params: { ...sharedParams, rotation_z: angle },
|
|
}),
|
|
),
|
|
buildWorkflowNode('output', 'output_save', 700, 195, {
|
|
label: 'Save Output',
|
|
type: 'outputNode',
|
|
}),
|
|
],
|
|
edges: [
|
|
{ from: 'setup', to: 'template' },
|
|
...angles.map((_, index) => ({ from: 'template', to: `render_${index}` })),
|
|
...angles.map((_, index) => ({ from: `render_${index}`, to: 'output' })),
|
|
],
|
|
}
|
|
}
|
|
|
|
if (type === 'still_with_exports') {
|
|
return {
|
|
version: 1,
|
|
ui: { preset: type, execution_mode: 'legacy', family: 'order_line' },
|
|
nodes: [
|
|
buildWorkflowNode('setup', 'order_line_setup', 0, 100, { label: 'Order Line Setup' }),
|
|
buildWorkflowNode('template', 'resolve_template', 220, 100, { label: 'Resolve Template' }),
|
|
buildWorkflowNode('render', 'blender_still', 440, 100, {
|
|
label: 'Still Render',
|
|
type: 'renderNode',
|
|
params: renderParams,
|
|
}),
|
|
buildWorkflowNode('output', 'output_save', 660, 70, {
|
|
label: 'Save Output',
|
|
type: 'outputNode',
|
|
}),
|
|
buildWorkflowNode('blend', 'export_blend', 660, 160, {
|
|
label: 'Export Blend',
|
|
type: 'outputNode',
|
|
}),
|
|
],
|
|
edges: [
|
|
{ from: 'setup', to: 'template' },
|
|
{ from: 'template', to: 'render' },
|
|
{ from: 'render', to: 'output' },
|
|
{ from: 'render', to: 'blend' },
|
|
],
|
|
}
|
|
}
|
|
|
|
return {
|
|
version: 1,
|
|
ui: { preset: 'custom', execution_mode: 'legacy', family: 'order_line' },
|
|
nodes: [
|
|
buildWorkflowNode('setup', 'order_line_setup', 120, 140, {
|
|
label: 'Order Line Setup',
|
|
type: 'processNode',
|
|
}),
|
|
],
|
|
edges: [],
|
|
}
|
|
}
|
|
|
|
export function buildWorkflowBlueprintConfig(blueprint: WorkflowBlueprintType): WorkflowConfig {
|
|
if (blueprint === 'cad_intake') {
|
|
return {
|
|
version: 1,
|
|
ui: { preset: 'custom', execution_mode: 'legacy', family: 'cad_file', blueprint },
|
|
nodes: [
|
|
buildWorkflowNode('resolve_step', 'resolve_step_path', 0, 180, { label: 'Resolve STEP Path' }),
|
|
buildWorkflowNode('extract_objects', 'occ_object_extract', 220, 180, {
|
|
label: 'Extract STEP Objects',
|
|
}),
|
|
buildWorkflowNode('export_glb', 'occ_glb_export', 440, 180, { label: 'Export GLB' }),
|
|
buildWorkflowNode('bbox', 'glb_bbox', 660, 120, {
|
|
label: 'Compute Bounding Box',
|
|
type: 'processNode',
|
|
}),
|
|
buildWorkflowNode('stl_cache', 'stl_cache_generate', 660, 300, { label: 'Generate STL Cache' }),
|
|
buildWorkflowNode('blender_thumb', 'blender_render', 880, 120, {
|
|
label: 'Render Thumbnail (Blender)',
|
|
type: 'renderNode',
|
|
params: { render_engine: 'cycles', samples: 64, width: 512, height: 512 },
|
|
}),
|
|
buildWorkflowNode('threejs_thumb', 'threejs_render', 880, 320, {
|
|
label: 'Render Thumbnail (Three.js)',
|
|
type: 'renderNode',
|
|
params: { width: 512, height: 512, transparent_bg: true },
|
|
}),
|
|
buildWorkflowNode('save_blender_thumb', 'thumbnail_save', 1100, 120, {
|
|
label: 'Save Blender Thumbnail',
|
|
type: 'outputNode',
|
|
}),
|
|
buildWorkflowNode('save_threejs_thumb', 'thumbnail_save', 1100, 320, {
|
|
label: 'Save Three.js Thumbnail',
|
|
type: 'outputNode',
|
|
}),
|
|
],
|
|
edges: [
|
|
{ from: 'resolve_step', to: 'extract_objects' },
|
|
{ from: 'extract_objects', to: 'export_glb' },
|
|
{ from: 'export_glb', to: 'bbox' },
|
|
{ from: 'export_glb', to: 'stl_cache' },
|
|
{ from: 'export_glb', to: 'blender_thumb' },
|
|
{ from: 'export_glb', to: 'threejs_thumb' },
|
|
{ from: 'bbox', to: 'threejs_thumb' },
|
|
{ from: 'blender_thumb', to: 'save_blender_thumb' },
|
|
{ from: 'threejs_thumb', to: 'save_threejs_thumb' },
|
|
],
|
|
}
|
|
}
|
|
|
|
if (blueprint === 'order_rendering') {
|
|
return {
|
|
version: 1,
|
|
ui: { preset: 'custom', execution_mode: 'legacy', family: 'order_line', blueprint },
|
|
nodes: [
|
|
buildWorkflowNode('setup', 'order_line_setup', 0, 220, { label: 'Order Line Setup' }),
|
|
buildWorkflowNode('template', 'resolve_template', 220, 220, { label: 'Resolve Template' }),
|
|
buildWorkflowNode('populate_materials', 'auto_populate_materials', 220, 360, {
|
|
label: 'Auto Populate Materials',
|
|
}),
|
|
buildWorkflowNode('bbox', 'glb_bbox', 220, 80, { label: 'Compute Bounding Box' }),
|
|
buildWorkflowNode('resolve_materials', 'material_map_resolve', 440, 220, {
|
|
label: 'Resolve Material Map',
|
|
}),
|
|
buildWorkflowNode('still_render', 'blender_still', 680, 80, {
|
|
label: 'Render Still',
|
|
type: 'renderNode',
|
|
params: { rotation_z: 0 },
|
|
}),
|
|
buildWorkflowNode('turntable_render', 'blender_turntable', 680, 220, {
|
|
label: 'Render Turntable',
|
|
type: 'renderFramesNode',
|
|
params: { fps: 24, duration_s: 5 },
|
|
}),
|
|
buildWorkflowNode('blend_export', 'export_blend', 680, 360, {
|
|
label: 'Export Blend',
|
|
type: 'outputNode',
|
|
}),
|
|
buildWorkflowNode('save_still', 'output_save', 920, 80, {
|
|
label: 'Save Still Output',
|
|
type: 'outputNode',
|
|
}),
|
|
buildWorkflowNode('save_turntable', 'output_save', 920, 220, {
|
|
label: 'Save Turntable Output',
|
|
type: 'outputNode',
|
|
}),
|
|
buildWorkflowNode('notify_still', 'notify', 920, 140, {
|
|
label: 'Notify Still Result',
|
|
type: 'outputNode',
|
|
}),
|
|
buildWorkflowNode('notify_turntable', 'notify', 920, 280, {
|
|
label: 'Notify Turntable Result',
|
|
type: 'outputNode',
|
|
}),
|
|
buildWorkflowNode('notify_export', 'notify', 920, 360, {
|
|
label: 'Notify Blend Export',
|
|
type: 'outputNode',
|
|
}),
|
|
],
|
|
edges: [
|
|
{ from: 'setup', to: 'template' },
|
|
{ from: 'setup', to: 'populate_materials' },
|
|
{ from: 'setup', to: 'bbox' },
|
|
{ from: 'template', to: 'resolve_materials' },
|
|
{ from: 'populate_materials', to: 'resolve_materials' },
|
|
{ from: 'resolve_materials', to: 'still_render' },
|
|
{ from: 'resolve_materials', to: 'turntable_render' },
|
|
{ from: 'bbox', to: 'still_render' },
|
|
{ from: 'bbox', to: 'turntable_render' },
|
|
{ from: 'template', to: 'still_render' },
|
|
{ from: 'template', to: 'turntable_render' },
|
|
{ from: 'template', to: 'blend_export' },
|
|
{ from: 'still_render', to: 'save_still' },
|
|
{ from: 'still_render', to: 'notify_still' },
|
|
{ from: 'turntable_render', to: 'save_turntable' },
|
|
{ from: 'turntable_render', to: 'notify_turntable' },
|
|
{ from: 'blend_export', to: 'notify_export' },
|
|
],
|
|
}
|
|
}
|
|
|
|
const stillReferenceParams: WorkflowParams = {
|
|
render_engine: 'cycles',
|
|
samples: 256,
|
|
width: 1920,
|
|
height: 1080,
|
|
}
|
|
const buildStillReference = () => {
|
|
if (blueprint === 'still_graph_alpha_reference') {
|
|
return buildOrderLineStillGraphNodes(stillReferenceParams, { transparentBg: true })
|
|
}
|
|
if (blueprint === 'still_graph_blend_reference') {
|
|
return buildOrderLineStillGraphNodes(stillReferenceParams, { includeBlendExport: true })
|
|
}
|
|
return buildOrderLineStillGraphNodes(stillReferenceParams)
|
|
}
|
|
const { nodes, edges } = buildStillReference()
|
|
|
|
return {
|
|
version: 1,
|
|
ui: { preset: 'custom', execution_mode: 'graph', family: 'order_line', blueprint },
|
|
nodes,
|
|
edges,
|
|
}
|
|
}
|
|
|
|
function buildStarterWorkflowConfigInternal(family: WorkflowStarterFamily = 'order_line'): WorkflowConfig {
|
|
if (family === 'cad_file') {
|
|
return {
|
|
version: 1,
|
|
ui: {
|
|
preset: 'custom',
|
|
execution_mode: 'legacy',
|
|
family: 'cad_file',
|
|
blueprint: 'starter_cad_intake',
|
|
},
|
|
nodes: [
|
|
buildWorkflowNode('resolve_step', 'resolve_step_path', 120, 140, {
|
|
label: 'Resolve STEP Path',
|
|
type: 'inputNode',
|
|
}),
|
|
],
|
|
edges: [],
|
|
}
|
|
}
|
|
|
|
return {
|
|
version: 1,
|
|
ui: {
|
|
preset: 'custom',
|
|
execution_mode: 'legacy',
|
|
family: 'order_line',
|
|
blueprint: 'starter_order_rendering',
|
|
},
|
|
nodes: [
|
|
buildWorkflowNode('setup', 'order_line_setup', 120, 140, {
|
|
label: 'Order Line Setup',
|
|
type: 'processNode',
|
|
}),
|
|
],
|
|
edges: [],
|
|
}
|
|
}
|
|
|
|
export function buildStillGraphNodes(renderParams: WorkflowParams): { nodes: WorkflowNode[]; edges: WorkflowEdge[] } {
|
|
return buildOrderLineStillGraphNodes(normalizeRenderParams(renderParams))
|
|
}
|
|
|
|
function migratePresetConfig(type: WorkflowPresetType, params: WorkflowParams = {}): WorkflowConfig {
|
|
return buildPresetWorkflowConfigInternal(type, params)
|
|
}
|
|
|
|
function normalizeWorkflowDefinition(raw: WorkflowDefinition): WorkflowDefinition {
|
|
const config = normalizeWorkflowConfig(raw.config as unknown as Record<string, unknown>)
|
|
return {
|
|
...raw,
|
|
family: raw.family ?? inferWorkflowFamily(config),
|
|
supported_artifact_kinds: Array.isArray(raw.supported_artifact_kinds)
|
|
? raw.supported_artifact_kinds
|
|
: [],
|
|
rollout_summary: {
|
|
linked_output_type_count: Number(raw.rollout_summary?.linked_output_type_count ?? 0),
|
|
active_output_type_count: Number(raw.rollout_summary?.active_output_type_count ?? 0),
|
|
linked_output_type_names: Array.isArray(raw.rollout_summary?.linked_output_type_names)
|
|
? raw.rollout_summary.linked_output_type_names
|
|
: [],
|
|
linked_output_types: Array.isArray(raw.rollout_summary?.linked_output_types)
|
|
? raw.rollout_summary.linked_output_types
|
|
.filter((outputType): outputType is WorkflowRolloutLinkedOutputType => (
|
|
outputType != null
|
|
&& typeof outputType === 'object'
|
|
&& typeof outputType.id === 'string'
|
|
&& typeof outputType.name === 'string'
|
|
))
|
|
.map(outputType => ({
|
|
id: outputType.id,
|
|
name: outputType.name,
|
|
is_active: Boolean(outputType.is_active),
|
|
artifact_kind: outputType.artifact_kind,
|
|
workflow_rollout_mode: outputType.workflow_rollout_mode ?? 'legacy_only',
|
|
}))
|
|
: [],
|
|
rollout_modes: Array.isArray(raw.rollout_summary?.rollout_modes)
|
|
? raw.rollout_summary.rollout_modes
|
|
: [],
|
|
has_blocking_contracts: Boolean(raw.rollout_summary?.has_blocking_contracts),
|
|
blocking_reasons: Array.isArray(raw.rollout_summary?.blocking_reasons)
|
|
? raw.rollout_summary.blocking_reasons
|
|
: [],
|
|
latest_run: raw.rollout_summary?.latest_run ?? null,
|
|
latest_shadow_run: raw.rollout_summary?.latest_shadow_run ?? null,
|
|
latest_rollout_gate_verdict: raw.rollout_summary?.latest_rollout_gate_verdict ?? null,
|
|
latest_rollout_ready:
|
|
typeof raw.rollout_summary?.latest_rollout_ready === 'boolean'
|
|
? raw.rollout_summary.latest_rollout_ready
|
|
: null,
|
|
latest_rollout_status: raw.rollout_summary?.latest_rollout_status ?? null,
|
|
latest_rollout_reasons: Array.isArray(raw.rollout_summary?.latest_rollout_reasons)
|
|
? raw.rollout_summary.latest_rollout_reasons
|
|
: [],
|
|
},
|
|
config,
|
|
}
|
|
}
|
|
|
|
export function normalizeWorkflowConfig(raw: Record<string, unknown>): WorkflowConfig {
|
|
if ('version' in raw && Array.isArray(raw.nodes)) {
|
|
const rawUi = (raw.ui as WorkflowUi | undefined) ?? {}
|
|
const nodes = (raw.nodes as WorkflowNode[]).map(node => ({
|
|
...node,
|
|
params: { ...(node.params ?? {}) },
|
|
}))
|
|
const edges = Array.isArray(raw.edges) ? (raw.edges as WorkflowEdge[]) : []
|
|
const mergedUi = {
|
|
...rawUi,
|
|
execution_mode: rawUi.execution_mode ?? 'legacy',
|
|
}
|
|
|
|
if (rawUi.preset === 'still_graph') {
|
|
const canonical = buildPresetWorkflowConfigInternal('still_graph', extractRenderParamsFromNodes(nodes, 'blender_still'))
|
|
return {
|
|
...canonical,
|
|
ui: {
|
|
...canonical.ui,
|
|
...mergedUi,
|
|
},
|
|
}
|
|
}
|
|
|
|
if (
|
|
rawUi.blueprint === 'cad_intake' ||
|
|
rawUi.blueprint === 'order_rendering' ||
|
|
rawUi.blueprint === 'still_graph_reference' ||
|
|
rawUi.blueprint === 'still_graph_alpha_reference' ||
|
|
rawUi.blueprint === 'still_graph_blend_reference'
|
|
) {
|
|
const canonical = buildWorkflowBlueprintConfig(rawUi.blueprint)
|
|
return {
|
|
...canonical,
|
|
ui: {
|
|
...canonical.ui,
|
|
...mergedUi,
|
|
},
|
|
}
|
|
}
|
|
|
|
if (rawUi.blueprint === 'starter_cad_intake' || rawUi.blueprint === 'starter_order_rendering') {
|
|
const canonical = buildStarterWorkflowConfigInternal(rawUi.blueprint === 'starter_cad_intake' ? 'cad_file' : 'order_line')
|
|
return {
|
|
...canonical,
|
|
ui: {
|
|
...canonical.ui,
|
|
...mergedUi,
|
|
},
|
|
}
|
|
}
|
|
|
|
return {
|
|
version: Number(raw.version ?? 1),
|
|
nodes,
|
|
edges,
|
|
ui: {
|
|
...mergedUi,
|
|
family: rawUi.family ?? inferWorkflowFamily({ version: Number(raw.version ?? 1), nodes, edges }) ?? undefined,
|
|
},
|
|
}
|
|
}
|
|
|
|
if (typeof raw.type === 'string') {
|
|
return migratePresetConfig(raw.type as WorkflowPresetType, (raw.params as WorkflowParams | undefined) ?? {})
|
|
}
|
|
|
|
return {
|
|
version: 1,
|
|
nodes: [],
|
|
edges: [],
|
|
ui: { preset: 'custom', execution_mode: 'legacy' },
|
|
}
|
|
}
|
|
|
|
export function createPresetWorkflowConfig(type: WorkflowPresetType, params: WorkflowParams = {}): WorkflowConfig {
|
|
return buildPresetWorkflowConfigInternal(type, params)
|
|
}
|
|
|
|
export function createStarterWorkflowConfig(family: WorkflowStarterFamily = 'order_line'): WorkflowConfig {
|
|
return buildStarterWorkflowConfigInternal(family)
|
|
}
|
|
|
|
export function getWorkflowPresetType(config: WorkflowConfig): WorkflowPresetType {
|
|
return config.ui?.preset ?? 'custom'
|
|
}
|
|
|
|
export function inferWorkflowFamily(config: WorkflowConfig): WorkflowNodeFamily | 'mixed' | null {
|
|
const families = new Set(
|
|
config.nodes
|
|
.map(node => {
|
|
switch (node.step) {
|
|
case 'resolve_step_path':
|
|
case 'occ_object_extract':
|
|
case 'occ_glb_export':
|
|
case 'stl_cache_generate':
|
|
case 'blender_render':
|
|
case 'threejs_render':
|
|
case 'thumbnail_save':
|
|
return 'cad_file'
|
|
case 'glb_bbox':
|
|
return null
|
|
case 'order_line_setup':
|
|
case 'resolve_template':
|
|
case 'material_map_resolve':
|
|
case 'auto_populate_materials':
|
|
case 'blender_still':
|
|
case 'blender_turntable':
|
|
case 'output_save':
|
|
case 'export_blend':
|
|
case 'notify':
|
|
return 'order_line'
|
|
default:
|
|
return null
|
|
}
|
|
})
|
|
.filter((family): family is Exclude<WorkflowNodeFamily, 'shared'> => family !== null),
|
|
)
|
|
if (families.size === 0) return null
|
|
if (families.size > 1) return 'mixed'
|
|
return Array.from(families)[0]
|
|
}
|