feat: harden workflow graph contracts

This commit is contained in:
2026-04-08 21:32:14 +02:00
parent 22981af1d2
commit bd18cccb5e
7 changed files with 1403 additions and 100 deletions
@@ -19,7 +19,7 @@ _PRESET_TYPES = {
} }
_EXECUTION_MODES = {"legacy", "graph", "shadow"} _EXECUTION_MODES = {"legacy", "graph", "shadow"}
_WORKFLOW_BLUEPRINTS = {"cad_intake", "order_rendering"} _WORKFLOW_BLUEPRINTS = {"cad_intake", "order_rendering", "still_graph_reference"}
_WORKFLOW_STARTERS = {"cad_file", "order_line"} _WORKFLOW_STARTERS = {"cad_file", "order_line"}
_NODE_TYPE_TO_STEP: dict[str, str] = { _NODE_TYPE_TO_STEP: dict[str, str] = {
@@ -63,6 +63,50 @@ def _resolution_to_dimensions(params: dict[str, Any]) -> dict[str, Any]:
return merged return merged
def _extract_render_params_from_nodes(nodes: list[dict[str, Any]], step: StepName) -> dict[str, Any]:
for node in nodes:
if node.get("step") == step.value:
return _resolution_to_dimensions(node.get("params") or {})
return {}
def _build_order_line_still_graph_nodes(render_params: dict[str, Any]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
graph_render_params = deepcopy(render_params)
graph_render_params.setdefault("use_custom_render_settings", True)
nodes = [
_make_node("setup", StepName.ORDER_LINE_SETUP, 0, 160, label="Order Line Setup"),
_make_node("template", StepName.RESOLVE_TEMPLATE, 220, 160, label="Resolve Template"),
_make_node("populate_materials", StepName.AUTO_POPULATE_MATERIALS, 220, 320, label="Auto Populate Materials"),
_make_node("bbox", StepName.GLB_BBOX, 220, 40, label="Compute Bounding Box"),
_make_node("resolve_materials", StepName.MATERIAL_MAP_RESOLVE, 440, 200, label="Resolve Material Map"),
_make_node(
"render",
StepName.BLENDER_STILL,
680,
160,
params=graph_render_params,
node_type="renderNode",
label="Still Render",
),
_make_node("output", StepName.OUTPUT_SAVE, 920, 120, label="Save Output"),
_make_node("notify", StepName.NOTIFY, 920, 220, label="Notify Result"),
]
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": "render"},
{"from": "bbox", "to": "render"},
{"from": "template", "to": "render"},
{"from": "render", "to": "output"},
{"from": "render", "to": "notify"},
]
return nodes, edges
def build_preset_workflow_config( def build_preset_workflow_config(
preset_type: WorkflowPresetType, preset_type: WorkflowPresetType,
params: dict[str, Any] | None = None, params: dict[str, Any] | None = None,
@@ -94,31 +138,7 @@ def build_preset_workflow_config(
{"from": "render", "to": "output"}, {"from": "render", "to": "output"},
] ]
elif preset_type == "still_graph": elif preset_type == "still_graph":
nodes = [ nodes, edges = _build_order_line_still_graph_nodes(render_params)
_make_node("setup", StepName.ORDER_LINE_SETUP, 0, 100, label="Order Line Setup"),
_make_node("populate_materials", StepName.AUTO_POPULATE_MATERIALS, 220, 100, label="Auto Populate Materials"),
_make_node("template", StepName.RESOLVE_TEMPLATE, 440, 100, label="Resolve Template"),
_make_node("resolve_materials", StepName.MATERIAL_MAP_RESOLVE, 660, 100, label="Resolve Material Map"),
_make_node(
"render",
StepName.BLENDER_STILL,
880,
100,
params=render_params,
node_type="renderNode",
label="Still Render",
),
_make_node("output", StepName.OUTPUT_SAVE, 1100, 70, label="Save Output"),
_make_node("notify", StepName.NOTIFY, 1100, 160, label="Notify Result"),
]
edges = [
{"from": "setup", "to": "populate_materials"},
{"from": "populate_materials", "to": "template"},
{"from": "template", "to": "resolve_materials"},
{"from": "resolve_materials", "to": "render"},
{"from": "render", "to": "output"},
{"from": "render", "to": "notify"},
]
elif preset_type == "turntable": elif preset_type == "turntable":
nodes = [ nodes = [
_make_node("setup", StepName.ORDER_LINE_SETUP, 0, 100, label="Order Line Setup"), _make_node("setup", StepName.ORDER_LINE_SETUP, 0, 100, label="Order Line Setup"),
@@ -246,17 +266,17 @@ def build_workflow_blueprint_config(blueprint: str) -> dict[str, Any]:
{"from": "blender_thumb", "to": "save_blender_thumb"}, {"from": "blender_thumb", "to": "save_blender_thumb"},
{"from": "threejs_thumb", "to": "save_threejs_thumb"}, {"from": "threejs_thumb", "to": "save_threejs_thumb"},
] ]
else: elif blueprint == "order_rendering":
nodes = [ nodes = [
_make_node("setup", StepName.ORDER_LINE_SETUP, 0, 220, label="Order Line Setup"), _make_node("setup", StepName.ORDER_LINE_SETUP, 0, 220, label="Order Line Setup"),
_make_node("template", StepName.RESOLVE_TEMPLATE, 220, 220, label="Resolve Template"),
_make_node("populate_materials", StepName.AUTO_POPULATE_MATERIALS, 220, 360, label="Auto Populate Materials"),
_make_node("bbox", StepName.GLB_BBOX, 220, 80, label="Compute Bounding Box"), _make_node("bbox", StepName.GLB_BBOX, 220, 80, label="Compute Bounding Box"),
_make_node("resolve_materials", StepName.MATERIAL_MAP_RESOLVE, 440, 80, label="Resolve Material Map"), _make_node("resolve_materials", StepName.MATERIAL_MAP_RESOLVE, 440, 220, label="Resolve Material Map"),
_make_node("populate_materials", StepName.AUTO_POPULATE_MATERIALS, 660, 80, label="Auto Populate Materials"),
_make_node("template", StepName.RESOLVE_TEMPLATE, 880, 220, label="Resolve Template"),
_make_node( _make_node(
"still_render", "still_render",
StepName.BLENDER_STILL, StepName.BLENDER_STILL,
1120, 680,
80, 80,
params={"rotation_z": 0}, params={"rotation_z": 0},
node_type="renderNode", node_type="renderNode",
@@ -265,33 +285,42 @@ def build_workflow_blueprint_config(blueprint: str) -> dict[str, Any]:
_make_node( _make_node(
"turntable_render", "turntable_render",
StepName.BLENDER_TURNTABLE, StepName.BLENDER_TURNTABLE,
1120, 680,
220, 220,
params={"fps": 24, "duration_s": 5}, params={"fps": 24, "duration_s": 5},
node_type="renderFramesNode", node_type="renderFramesNode",
label="Render Turntable", label="Render Turntable",
), ),
_make_node("blend_export", StepName.EXPORT_BLEND, 1120, 360, label="Export Blend"), _make_node("blend_export", StepName.EXPORT_BLEND, 680, 360, label="Export Blend"),
_make_node("save_still", StepName.OUTPUT_SAVE, 1360, 80, label="Save Still Output"), _make_node("save_still", StepName.OUTPUT_SAVE, 920, 80, label="Save Still Output"),
_make_node("save_turntable", StepName.OUTPUT_SAVE, 1360, 220, label="Save Turntable Output"), _make_node("save_turntable", StepName.OUTPUT_SAVE, 920, 220, label="Save Turntable Output"),
_make_node("notify_still", StepName.NOTIFY, 1600, 80, label="Notify Still Result"), _make_node("notify_still", StepName.NOTIFY, 920, 140, label="Notify Still Result"),
_make_node("notify_turntable", StepName.NOTIFY, 1600, 220, label="Notify Turntable Result"), _make_node("notify_turntable", StepName.NOTIFY, 920, 280, label="Notify Turntable Result"),
_make_node("notify_export", StepName.NOTIFY, 1360, 360, label="Notify Blend Export"), _make_node("notify_export", StepName.NOTIFY, 920, 360, label="Notify Blend Export"),
] ]
edges = [ edges = [
{"from": "setup", "to": "template"},
{"from": "setup", "to": "populate_materials"},
{"from": "setup", "to": "bbox"}, {"from": "setup", "to": "bbox"},
{"from": "bbox", "to": "resolve_materials"}, {"from": "template", "to": "resolve_materials"},
{"from": "resolve_materials", "to": "populate_materials"}, {"from": "populate_materials", "to": "resolve_materials"},
{"from": "populate_materials", "to": "template"}, {"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": "still_render"},
{"from": "template", "to": "turntable_render"}, {"from": "template", "to": "turntable_render"},
{"from": "template", "to": "blend_export"}, {"from": "template", "to": "blend_export"},
{"from": "still_render", "to": "save_still"}, {"from": "still_render", "to": "save_still"},
{"from": "still_render", "to": "notify_still"},
{"from": "turntable_render", "to": "save_turntable"}, {"from": "turntable_render", "to": "save_turntable"},
{"from": "save_still", "to": "notify_still"}, {"from": "turntable_render", "to": "notify_turntable"},
{"from": "save_turntable", "to": "notify_turntable"},
{"from": "blend_export", "to": "notify_export"}, {"from": "blend_export", "to": "notify_export"},
] ]
elif blueprint == "still_graph_reference":
nodes, edges = _build_order_line_still_graph_nodes(
{"render_engine": "cycles", "samples": 256, "width": 1920, "height": 1080}
)
return { return {
"version": 1, "version": 1,
@@ -299,7 +328,7 @@ def build_workflow_blueprint_config(blueprint: str) -> dict[str, Any]:
"edges": edges, "edges": edges,
"ui": { "ui": {
"preset": "custom", "preset": "custom",
"execution_mode": "legacy", "execution_mode": "graph" if blueprint == "still_graph_reference" else "legacy",
"blueprint": blueprint, "blueprint": blueprint,
}, },
} }
@@ -438,6 +467,26 @@ def canonicalize_workflow_config(raw: dict[str, Any]) -> dict[str, Any]:
ui = {} ui = {}
normalized["ui"] = dict(ui) normalized["ui"] = dict(ui)
normalized["ui"].setdefault("execution_mode", "legacy") normalized["ui"].setdefault("execution_mode", "legacy")
preset = normalized["ui"].get("preset")
blueprint = normalized["ui"].get("blueprint")
if preset == "still_graph":
merged_ui = dict(normalized["ui"])
canonical = build_preset_workflow_config(
"still_graph",
_extract_render_params_from_nodes(normalized.get("nodes") or [], StepName.BLENDER_STILL),
)
merged_ui.setdefault("execution_mode", canonical["ui"]["execution_mode"])
canonical["ui"].update(merged_ui)
return canonical
if blueprint == "still_graph_reference":
merged_ui = dict(normalized["ui"])
canonical = build_workflow_blueprint_config("still_graph_reference")
merged_ui.setdefault("execution_mode", canonical["ui"]["execution_mode"])
canonical["ui"].update(merged_ui)
return canonical
return normalized return normalized
workflow_type = raw.get("type") workflow_type = raw.get("type")
@@ -451,6 +500,9 @@ def canonicalize_workflow_config(raw: dict[str, Any]) -> dict[str, Any]:
merged_ui.update(raw_ui) merged_ui.update(raw_ui)
if merged_ui.get("execution_mode") not in _EXECUTION_MODES: if merged_ui.get("execution_mode") not in _EXECUTION_MODES:
merged_ui["execution_mode"] = "legacy" merged_ui["execution_mode"] = "legacy"
if workflow_type == "still" and merged_ui.get("execution_mode") == "graph":
canonical = build_preset_workflow_config("still_graph", raw.get("params") or {})
merged_ui = dict(canonical.get("ui") or {}) | merged_ui
canonical["ui"] = merged_ui canonical["ui"] = merged_ui
return canonical return canonical
@@ -8,8 +8,9 @@ from app.core.process_steps import StepName
StepCategory = Literal["input", "processing", "rendering", "output"] StepCategory = Literal["input", "processing", "rendering", "output"]
FieldType = Literal["number", "select", "boolean"] FieldType = Literal["number", "select", "boolean", "text"]
ExecutionKind = Literal["native", "bridge"] ExecutionKind = Literal["native", "bridge"]
WorkflowNodeFamily = Literal["cad_file", "order_line"]
class WorkflowNodeFieldOption(BaseModel): class WorkflowNodeFieldOption(BaseModel):
@@ -34,6 +35,8 @@ class WorkflowNodeFieldDefinition(BaseModel):
class WorkflowNodeDefinition(BaseModel): class WorkflowNodeDefinition(BaseModel):
step: str step: str
label: str label: str
family: WorkflowNodeFamily
module_key: str
category: StepCategory category: StepCategory
description: str description: str
node_type: str node_type: str
@@ -42,6 +45,11 @@ class WorkflowNodeDefinition(BaseModel):
fields: list[WorkflowNodeFieldDefinition] = [] fields: list[WorkflowNodeFieldDefinition] = []
execution_kind: ExecutionKind = "native" execution_kind: ExecutionKind = "native"
legacy_compatible: bool = True legacy_compatible: bool = True
input_contract: dict[str, Any] = {}
output_contract: dict[str, Any] = {}
artifact_roles_produced: list[str] = []
artifact_roles_consumed: list[str] = []
legacy_source: str | None = None
def _field( def _field(
@@ -79,6 +87,8 @@ def _field(
def _definition( def _definition(
step: StepName, step: StepName,
label: str, label: str,
family: WorkflowNodeFamily,
module_key: str,
category: StepCategory, category: StepCategory,
description: str, description: str,
*, *,
@@ -87,10 +97,17 @@ def _definition(
defaults: dict[str, Any] | None = None, defaults: dict[str, Any] | None = None,
fields: list[WorkflowNodeFieldDefinition] | None = None, fields: list[WorkflowNodeFieldDefinition] | None = None,
execution_kind: ExecutionKind = "native", execution_kind: ExecutionKind = "native",
input_contract: dict[str, Any] | None = None,
output_contract: dict[str, Any] | None = None,
artifact_roles_produced: list[str] | None = None,
artifact_roles_consumed: list[str] | None = None,
legacy_source: str | None = None,
) -> WorkflowNodeDefinition: ) -> WorkflowNodeDefinition:
return WorkflowNodeDefinition( return WorkflowNodeDefinition(
step=step.value, step=step.value,
label=label, label=label,
family=family,
module_key=module_key,
category=category, category=category,
description=description, description=description,
node_type=node_type, node_type=node_type,
@@ -98,67 +115,129 @@ def _definition(
defaults=defaults or {}, defaults=defaults or {},
fields=fields or [], fields=fields or [],
execution_kind=execution_kind, execution_kind=execution_kind,
input_contract=input_contract or {},
output_contract=output_contract or {},
artifact_roles_produced=artifact_roles_produced or [],
artifact_roles_consumed=artifact_roles_consumed or [],
legacy_source=legacy_source or f"legacy_step:{step.value}",
) )
_BLENDER_ENGINE_OPTIONS = [("cycles", "Cycles"), ("eevee", "EEVEE")]
_CYCLES_DEVICE_OPTIONS = [("auto", "Auto"), ("gpu", "GPU"), ("cpu", "CPU")]
_GPU_TOGGLE_OPTIONS = [("", "Inherited / Default"), ("1", "Enabled"), ("0", "Disabled")]
_TURNTABLE_AXIS_OPTIONS = [
("world_z", "World Z"),
("world_x", "World X"),
("world_y", "World Y"),
]
_NODE_DEFINITIONS: list[WorkflowNodeDefinition] = [ _NODE_DEFINITIONS: list[WorkflowNodeDefinition] = [
_definition( _definition(
StepName.RESOLVE_STEP_PATH, StepName.RESOLVE_STEP_PATH,
"Resolve STEP Path", "Resolve STEP Path",
"cad_file",
"cad.resolve_step_path",
"input", "input",
"Locate the STEP file on disk from the CAD file record.", "Locate the STEP file on disk from the CAD file record.",
node_type="inputNode", node_type="inputNode",
icon="file-up", icon="file-up",
execution_kind="bridge", execution_kind="bridge",
input_contract={"context": "cad_file", "requires": ["cad_file_record"]},
output_contract={"context": "cad_file", "provides": ["step_path", "step_file_hash"]},
artifact_roles_produced=["step_path"],
), ),
_definition( _definition(
StepName.OCC_OBJECT_EXTRACT, StepName.OCC_OBJECT_EXTRACT,
"Extract STEP Objects", "Extract STEP Objects",
"cad_file",
"cad.extract_objects",
"processing", "processing",
"Extract part objects and metadata from the STEP file via OCC/cadquery.", "Extract part objects and metadata from the STEP file via OCC/cadquery.",
node_type="processNode", node_type="processNode",
icon="layers", icon="layers",
execution_kind="bridge", execution_kind="bridge",
input_contract={"context": "cad_file", "requires": ["step_path"]},
output_contract={"context": "cad_file", "provides": ["cad_objects", "cad_metadata"]},
artifact_roles_consumed=["step_path"],
artifact_roles_produced=["cad_objects", "cad_metadata"],
), ),
_definition( _definition(
StepName.OCC_GLB_EXPORT, StepName.OCC_GLB_EXPORT,
"Export GLB", "Export GLB",
"cad_file",
"cad.export_glb",
"processing", "processing",
"Convert STEP geometry into GLB for previews and downstream rendering.", "Convert STEP geometry into GLB for previews and downstream rendering.",
node_type="processNode", node_type="processNode",
icon="refresh-cw", icon="refresh-cw",
execution_kind="bridge", execution_kind="bridge",
input_contract={"context": "cad_file", "requires": ["step_path"]},
output_contract={"context": "cad_file", "provides": ["glb_preview"]},
artifact_roles_consumed=["step_path"],
artifact_roles_produced=["glb_preview"],
), ),
_definition( _definition(
StepName.GLB_BBOX, StepName.GLB_BBOX,
"Compute Bounding Box", "Compute Bounding Box",
"order_line",
"geometry.compute_bbox",
"processing", "processing",
"Compute the model bounding box from the exported GLB for framing decisions.", "Compute the model bounding box from the exported GLB for framing decisions.",
node_type="processNode", node_type="processNode",
icon="layers", icon="layers",
execution_kind="bridge", execution_kind="bridge",
fields=[
_field(
"glb_path",
"GLB Path Override",
"text",
description="Optional absolute path to a specific GLB file. Leave empty to reuse the prepared preview/export artifact automatically.",
section="Inputs",
default="",
),
],
input_contract={"context": "order_line", "requires": ["glb_preview"]},
output_contract={"context": "order_line", "provides": ["bbox"]},
artifact_roles_consumed=["glb_preview"],
artifact_roles_produced=["bbox"],
), ),
_definition( _definition(
StepName.MATERIAL_MAP_RESOLVE, StepName.MATERIAL_MAP_RESOLVE,
"Resolve Material Map", "Resolve Material Map",
"order_line",
"materials.resolve_map",
"processing", "processing",
"Map raw part material names to HartOMat material records via aliases.", "Map raw part material names to HartOMat material records via aliases.",
node_type="processNode", node_type="processNode",
icon="layers", icon="layers",
execution_kind="bridge", execution_kind="bridge",
input_contract={"context": "order_line", "requires": ["order_line_context", "cad_materials"]},
output_contract={"context": "order_line", "provides": ["material_assignments"]},
artifact_roles_consumed=["order_line_context", "cad_materials"],
artifact_roles_produced=["material_assignments"],
), ),
_definition( _definition(
StepName.AUTO_POPULATE_MATERIALS, StepName.AUTO_POPULATE_MATERIALS,
"Auto Populate Materials", "Auto Populate Materials",
"order_line",
"materials.auto_populate",
"processing", "processing",
"Create missing material records for newly discovered part materials.", "Create missing material records for newly discovered part materials.",
node_type="processNode", node_type="processNode",
icon="layers", icon="layers",
execution_kind="bridge", execution_kind="bridge",
input_contract={"context": "order_line", "requires": ["cad_materials"]},
output_contract={"context": "order_line", "provides": ["material_catalog_updates"]},
artifact_roles_consumed=["cad_materials"],
artifact_roles_produced=["material_catalog_updates"],
), ),
_definition( _definition(
StepName.BLENDER_RENDER, StepName.BLENDER_RENDER,
"Render Thumbnail (Blender)", "Render Thumbnail (Blender)",
"cad_file",
"render.thumbnail.blender",
"rendering", "rendering",
"Render a thumbnail image with Blender.", "Render a thumbnail image with Blender.",
node_type="renderNode", node_type="renderNode",
@@ -172,7 +251,7 @@ _NODE_DEFINITIONS: list[WorkflowNodeDefinition] = [
description="Renderer backend for this Blender job.", description="Renderer backend for this Blender job.",
section="Render", section="Render",
default="cycles", default="cycles",
options=[("cycles", "Cycles"), ("eevee", "EEVEE")], options=_BLENDER_ENGINE_OPTIONS,
), ),
_field( _field(
"samples", "samples",
@@ -188,10 +267,16 @@ _NODE_DEFINITIONS: list[WorkflowNodeDefinition] = [
_field("width", "Width", "number", section="Output", default=512, min=64, max=8192, step=1, unit="px"), _field("width", "Width", "number", section="Output", default=512, min=64, max=8192, step=1, unit="px"),
_field("height", "Height", "number", section="Output", default=512, min=64, max=8192, step=1, unit="px"), _field("height", "Height", "number", section="Output", default=512, min=64, max=8192, step=1, unit="px"),
], ],
input_contract={"context": "cad_file", "requires": ["step_path"]},
output_contract={"context": "cad_file", "provides": ["rendered_image"]},
artifact_roles_consumed=["step_path"],
artifact_roles_produced=["rendered_image"],
), ),
_definition( _definition(
StepName.THREEJS_RENDER, StepName.THREEJS_RENDER,
"Render Thumbnail (Three.js)", "Render Thumbnail (Three.js)",
"cad_file",
"render.thumbnail.threejs",
"rendering", "rendering",
"Render a thumbnail image with the headless Three.js renderer.", "Render a thumbnail image with the headless Three.js renderer.",
node_type="renderNode", node_type="renderNode",
@@ -210,43 +295,116 @@ _NODE_DEFINITIONS: list[WorkflowNodeDefinition] = [
), ),
], ],
execution_kind="bridge", execution_kind="bridge",
input_contract={"context": "cad_file", "requires": ["glb_preview", "bbox"]},
output_contract={"context": "cad_file", "provides": ["rendered_image"]},
artifact_roles_consumed=["glb_preview", "bbox"],
artifact_roles_produced=["rendered_image"],
), ),
_definition( _definition(
StepName.THUMBNAIL_SAVE, StepName.THUMBNAIL_SAVE,
"Save Thumbnail", "Save Thumbnail",
"cad_file",
"media.save_thumbnail",
"output", "output",
"Persist the generated thumbnail back onto the CAD file record.", "Persist the generated thumbnail back onto the CAD file record.",
node_type="outputNode", node_type="outputNode",
icon="download", icon="download",
execution_kind="bridge", execution_kind="bridge",
input_contract={"context": "cad_file", "requires": ["rendered_image"]},
output_contract={"context": "cad_file", "provides": ["cad_thumbnail_media"]},
artifact_roles_consumed=["rendered_image"],
artifact_roles_produced=["cad_thumbnail_media"],
), ),
_definition( _definition(
StepName.ORDER_LINE_SETUP, StepName.ORDER_LINE_SETUP,
"Order Line Setup", "Order Line Setup",
"order_line",
"order_line.prepare_render_context",
"processing", "processing",
"Validate order-line inputs and prepare the render job context.", "Validate order-line inputs and prepare the render job context.",
node_type="processNode", node_type="processNode",
icon="layers", icon="layers",
execution_kind="bridge", execution_kind="bridge",
input_contract={"context": "order_line", "requires": ["order_line_record"]},
output_contract={
"context": "order_line",
"provides": [
"order_line_context",
"cad_file_ref",
"step_path",
"cad_materials",
"glb_preview",
"bbox",
"usd_render_path",
"glb_reuse_path",
],
},
artifact_roles_produced=[
"order_line_context",
"cad_file_ref",
"step_path",
"cad_materials",
"glb_preview",
"bbox",
"usd_render_path",
"glb_reuse_path",
],
), ),
_definition( _definition(
StepName.RESOLVE_TEMPLATE, StepName.RESOLVE_TEMPLATE,
"Resolve Template", "Resolve Template",
"order_line",
"rendering.resolve_template",
"processing", "processing",
"Resolve the render template for the order line and output type.", "Resolve the render template for the order line and output type.",
node_type="processNode", node_type="processNode",
icon="layers", icon="layers",
execution_kind="bridge", execution_kind="bridge",
input_contract={"context": "order_line", "requires": ["order_line_context"]},
output_contract={
"context": "order_line",
"provides": [
"render_template",
"output_profile",
"template_path",
"material_library",
"material_map",
"use_materials",
"override_material",
"category_key",
],
},
artifact_roles_consumed=["order_line_context"],
artifact_roles_produced=[
"render_template",
"output_profile",
"template_path",
"material_library",
"material_map",
"use_materials",
"override_material",
"category_key",
],
), ),
_definition( _definition(
StepName.BLENDER_STILL, StepName.BLENDER_STILL,
"Render Still", "Render Still",
"order_line",
"render.production.still",
"rendering", "rendering",
"Render a production still image with Blender.", "Render a production still image with Blender.",
node_type="renderNode", node_type="renderNode",
icon="camera", icon="camera",
defaults={"render_engine": "cycles", "samples": 256, "width": 2048, "height": 2048, "rotation_z": 0}, defaults={"use_custom_render_settings": False, "rotation_z": 0},
fields=[ fields=[
_field(
"use_custom_render_settings",
"Custom Render Settings",
"boolean",
description="Enable explicit engine, sample, and resolution overrides for Graph/Shadow mode. When disabled, authoritative output-type and template settings are inherited.",
section="Render",
default=False,
),
_field( _field(
"render_engine", "render_engine",
"Render Engine", "Render Engine",
@@ -254,7 +412,16 @@ _NODE_DEFINITIONS: list[WorkflowNodeDefinition] = [
description="Renderer backend for the still render.", description="Renderer backend for the still render.",
section="Render", section="Render",
default="cycles", default="cycles",
options=[("cycles", "Cycles"), ("eevee", "EEVEE")], options=_BLENDER_ENGINE_OPTIONS,
),
_field(
"cycles_device",
"Cycles Device",
"select",
description="Force CPU, GPU, or automatic device selection.",
section="Render",
default="auto",
options=_CYCLES_DEVICE_OPTIONS,
), ),
_field( _field(
"samples", "samples",
@@ -269,6 +436,111 @@ _NODE_DEFINITIONS: list[WorkflowNodeDefinition] = [
), ),
_field("width", "Width", "number", section="Output", default=2048, min=64, max=8192, step=1, unit="px"), _field("width", "Width", "number", section="Output", default=2048, min=64, max=8192, step=1, unit="px"),
_field("height", "Height", "number", section="Output", default=2048, min=64, max=8192, step=1, unit="px"), _field("height", "Height", "number", section="Output", default=2048, min=64, max=8192, step=1, unit="px"),
_field(
"transparent_bg",
"Transparent Background",
"boolean",
description="Render with alpha output instead of an opaque background.",
section="Output",
default=False,
),
_field(
"noise_threshold",
"Noise Threshold",
"text",
description="Optional Cycles adaptive sampling threshold, for example 0.01.",
section="Denoising",
default="",
),
_field(
"denoiser",
"Denoiser",
"text",
description="Optional Blender denoiser name, for example OPENIMAGEDENOISE or OPTIX.",
section="Denoising",
default="",
),
_field(
"denoising_input_passes",
"Input Passes",
"text",
description="Optional Cycles denoising input pass mode.",
section="Denoising",
default="",
),
_field(
"denoising_prefilter",
"Prefilter",
"text",
description="Optional Cycles denoising prefilter mode.",
section="Denoising",
default="",
),
_field(
"denoising_quality",
"Denoising Quality",
"text",
description="Optional Cycles denoising quality mode.",
section="Denoising",
default="",
),
_field(
"denoising_use_gpu",
"Use GPU Denoising",
"select",
description="Override whether Blender denoising should run on the GPU.",
section="Denoising",
default="",
options=_GPU_TOGGLE_OPTIONS,
),
_field(
"target_collection",
"Target Collection",
"text",
description="Template collection name that receives the imported product geometry.",
section="Scene",
default="Product",
),
_field(
"lighting_only",
"Lighting Only",
"boolean",
description="Use template lighting and auto-framing without template materials.",
section="Scene",
default=False,
),
_field(
"shadow_catcher",
"Shadow Catcher",
"boolean",
description="Enable a shadow catcher plane for composited renders.",
section="Scene",
default=False,
),
_field(
"rotation_x",
"Rotation X",
"number",
description="Additional X-axis rotation in degrees.",
section="Camera",
default=0,
min=-360,
max=360,
step=1,
unit="deg",
),
_field(
"rotation_y",
"Rotation Y",
"number",
description="Additional Y-axis rotation in degrees.",
section="Camera",
default=0,
min=-360,
max=360,
step=1,
unit="deg",
),
_field( _field(
"rotation_z", "rotation_z",
"Rotation Z", "Rotation Z",
@@ -281,25 +553,71 @@ _NODE_DEFINITIONS: list[WorkflowNodeDefinition] = [
step=1, step=1,
unit="deg", unit="deg",
), ),
_field(
"focal_length_mm",
"Focal Length",
"number",
description="Optional camera focal length override.",
section="Camera",
default=None,
min=1,
max=500,
step=0.1,
unit="mm",
),
_field(
"sensor_width_mm",
"Sensor Width",
"number",
description="Optional camera sensor width override.",
section="Camera",
default=None,
min=1,
max=100,
step=0.1,
unit="mm",
),
_field(
"material_override",
"Material Override",
"text",
description="Optional material name forced onto all parts during rendering.",
section="Materials",
default="",
),
], ],
input_contract={
"context": "order_line",
"requires": ["order_line_context", "render_template", "material_assignments", "bbox"],
},
output_contract={"context": "order_line", "provides": ["rendered_image"]},
artifact_roles_consumed=["order_line_context", "render_template", "material_assignments", "bbox"],
artifact_roles_produced=["rendered_image"],
), ),
_definition( _definition(
StepName.BLENDER_TURNTABLE, StepName.BLENDER_TURNTABLE,
"Render Turntable", "Render Turntable",
"order_line",
"render.production.turntable",
"rendering", "rendering",
"Render an animated turntable sequence with Blender.", "Render an animated turntable sequence with Blender.",
node_type="renderFramesNode", node_type="renderFramesNode",
icon="film", icon="film",
defaults={ defaults={
"render_engine": "cycles", "use_custom_render_settings": False,
"samples": 64,
"width": 2048,
"height": 2048,
"fps": 24, "fps": 24,
"duration_s": 5, "duration_s": 5,
"rotation_z": 0, "rotation_z": 0,
}, },
fields=[ fields=[
_field(
"use_custom_render_settings",
"Custom Render Settings",
"boolean",
description="Enable explicit engine, sample, and resolution overrides for Graph/Shadow mode. When disabled, authoritative output-type and template settings are inherited.",
section="Render",
default=False,
),
_field( _field(
"render_engine", "render_engine",
"Render Engine", "Render Engine",
@@ -307,7 +625,16 @@ _NODE_DEFINITIONS: list[WorkflowNodeDefinition] = [
description="Renderer backend for the turntable job.", description="Renderer backend for the turntable job.",
section="Render", section="Render",
default="cycles", default="cycles",
options=[("cycles", "Cycles"), ("eevee", "EEVEE")], options=_BLENDER_ENGINE_OPTIONS,
),
_field(
"cycles_device",
"Cycles Device",
"select",
description="Force CPU, GPU, or automatic device selection.",
section="Render",
default="auto",
options=_CYCLES_DEVICE_OPTIONS,
), ),
_field( _field(
"samples", "samples",
@@ -322,6 +649,22 @@ _NODE_DEFINITIONS: list[WorkflowNodeDefinition] = [
), ),
_field("width", "Width", "number", section="Output", default=2048, min=64, max=8192, step=1, unit="px"), _field("width", "Width", "number", section="Output", default=2048, min=64, max=8192, step=1, unit="px"),
_field("height", "Height", "number", section="Output", default=2048, min=64, max=8192, step=1, unit="px"), _field("height", "Height", "number", section="Output", default=2048, min=64, max=8192, step=1, unit="px"),
_field(
"transparent_bg",
"Transparent Background",
"boolean",
description="Render transparent PNG frames for alpha output or FFmpeg background compositing.",
section="Output",
default=False,
),
_field(
"bg_color",
"Background Color",
"text",
description="Optional hex color used during FFmpeg compositing, for example #FFFFFF.",
section="Output",
default="",
),
_field("fps", "FPS", "number", section="Animation", default=24, min=1, max=120, step=1), _field("fps", "FPS", "number", section="Animation", default=24, min=1, max=120, step=1),
_field( _field(
"duration_s", "duration_s",
@@ -335,6 +678,83 @@ _NODE_DEFINITIONS: list[WorkflowNodeDefinition] = [
step=1, step=1,
unit="s", unit="s",
), ),
_field(
"turntable_degrees",
"Turntable Degrees",
"number",
description="How far the model or camera rotates during the animation.",
section="Animation",
default=360,
min=1,
max=3600,
step=1,
unit="deg",
),
_field(
"turntable_axis",
"Turntable Axis",
"select",
description="Axis used for the model or camera orbit.",
section="Animation",
default="world_z",
options=_TURNTABLE_AXIS_OPTIONS,
),
_field(
"camera_orbit",
"Camera Orbit",
"boolean",
description="Orbit the camera instead of rotating the model.",
section="Animation",
default=True,
),
_field(
"target_collection",
"Target Collection",
"text",
description="Template collection name that receives the imported product geometry.",
section="Scene",
default="Product",
),
_field(
"lighting_only",
"Lighting Only",
"boolean",
description="Use template lighting and auto-framing without template materials.",
section="Scene",
default=False,
),
_field(
"shadow_catcher",
"Shadow Catcher",
"boolean",
description="Enable a shadow catcher plane for composited renders.",
section="Scene",
default=False,
),
_field(
"rotation_x",
"Rotation X",
"number",
description="Additional X-axis rotation in degrees.",
section="Camera",
default=0,
min=-360,
max=360,
step=1,
unit="deg",
),
_field(
"rotation_y",
"Rotation Y",
"number",
description="Additional Y-axis rotation in degrees.",
section="Camera",
default=0,
min=-360,
max=360,
step=1,
unit="deg",
),
_field( _field(
"rotation_z", "rotation_z",
"Rotation Z", "Rotation Z",
@@ -347,38 +767,112 @@ _NODE_DEFINITIONS: list[WorkflowNodeDefinition] = [
step=1, step=1,
unit="deg", unit="deg",
), ),
_field(
"focal_length_mm",
"Focal Length",
"number",
description="Optional camera focal length override.",
section="Camera",
default=None,
min=1,
max=500,
step=0.1,
unit="mm",
),
_field(
"sensor_width_mm",
"Sensor Width",
"number",
description="Optional camera sensor width override.",
section="Camera",
default=None,
min=1,
max=100,
step=0.1,
unit="mm",
),
_field(
"material_override",
"Material Override",
"text",
description="Optional material name forced onto all parts during rendering.",
section="Materials",
default="",
),
], ],
input_contract={
"context": "order_line",
"requires": ["order_line_context", "render_template", "material_assignments", "bbox"],
},
output_contract={"context": "order_line", "provides": ["rendered_frames", "rendered_video"]},
artifact_roles_consumed=["order_line_context", "render_template", "material_assignments", "bbox"],
artifact_roles_produced=["rendered_frames", "rendered_video"],
), ),
_definition( _definition(
StepName.OUTPUT_SAVE, StepName.OUTPUT_SAVE,
"Save Output", "Save Output",
"order_line",
"media.save_output",
"output", "output",
"Persist the rendered output file and create the media record.", "Persist the rendered output file and create the media record.",
node_type="outputNode", node_type="outputNode",
icon="download", icon="download",
execution_kind="bridge", execution_kind="bridge",
input_contract={
"context": "order_line",
"requires": ["order_line_context"],
"requires_any": ["rendered_image", "rendered_frames", "rendered_video"],
},
output_contract={"context": "order_line", "provides": ["media_asset", "workflow_result"]},
artifact_roles_consumed=["rendered_image", "rendered_frames", "rendered_video"],
artifact_roles_produced=["media_asset", "workflow_result"],
), ),
_definition( _definition(
StepName.EXPORT_BLEND, StepName.EXPORT_BLEND,
"Export Blend", "Export Blend",
"order_line",
"media.export_blend",
"output", "output",
"Persist the generated .blend file as a downloadable media asset.", "Persist the generated .blend file as a downloadable media asset.",
node_type="outputNode", node_type="outputNode",
icon="download", icon="download",
defaults={"output_name_suffix": ""},
fields=[
_field(
"output_name_suffix",
"Output Name Suffix",
"text",
description="Optional suffix appended to the generated `.blend` filename.",
section="Output",
default="",
),
],
execution_kind="bridge", execution_kind="bridge",
input_contract={"context": "order_line", "requires": ["order_line_context", "render_template"]},
output_contract={"context": "order_line", "provides": ["blend_asset"]},
artifact_roles_consumed=["order_line_context", "render_template"],
artifact_roles_produced=["blend_asset"],
), ),
_definition( _definition(
StepName.STL_CACHE_GENERATE, StepName.STL_CACHE_GENERATE,
"Generate STL Cache", "Generate STL Cache",
"cad_file",
"cad.generate_stl_cache",
"processing", "processing",
"Generate and cache STL derivatives next to the STEP source.", "Generate and cache STL derivatives next to the STEP source.",
node_type="convertNode", node_type="convertNode",
icon="refresh-cw", icon="refresh-cw",
execution_kind="bridge", execution_kind="bridge",
input_contract={"context": "cad_file", "requires": ["step_path"]},
output_contract={"context": "cad_file", "provides": ["stl_cache"]},
artifact_roles_consumed=["step_path"],
artifact_roles_produced=["stl_cache"],
), ),
_definition( _definition(
StepName.NOTIFY, StepName.NOTIFY,
"Notify", "Notify",
"order_line",
"notifications.emit",
"output", "output",
"Emit a user-visible notification for workflow completion or failure.", "Emit a user-visible notification for workflow completion or failure.",
node_type="outputNode", node_type="outputNode",
@@ -396,6 +890,14 @@ _NODE_DEFINITIONS: list[WorkflowNodeDefinition] = [
), ),
], ],
execution_kind="bridge", execution_kind="bridge",
input_contract={
"context": "order_line",
"requires": ["order_line_context"],
"requires_any": ["rendered_image", "rendered_frames", "rendered_video", "workflow_result"],
},
output_contract={"context": "order_line", "provides": ["notification_event"]},
artifact_roles_consumed=["workflow_result"],
artifact_roles_produced=["notification_event"],
), ),
] ]
@@ -1,6 +1,6 @@
"""Pydantic schema for validated WorkflowDefinition.config JSONB. """Pydantic schema for validated WorkflowDefinition.config JSONB.
A workflow config is a versioned DAG description stored as JSONB. Before A workflow config is a versioned DAG description stored as JSONB. Before
being dispatched (or saved), the raw dict must pass this schema. being dispatched (or saved), the raw dict must pass this schema.
Example config:: Example config::
@@ -16,11 +16,62 @@ Example config::
] ]
} }
""" """
from typing import Literal from collections import deque
from typing import Any, Literal
from pydantic import BaseModel, Field, field_validator, model_validator from pydantic import BaseModel, Field, field_validator, model_validator
from app.core.process_steps import StepName from app.core.process_steps import StepName
from app.domains.rendering.workflow_node_registry import (
WorkflowNodeDefinition,
WorkflowNodeFieldDefinition,
get_node_definition,
)
def _context_seed_artifacts(definition: WorkflowNodeDefinition) -> set[str]:
if definition.family == "order_line":
return {"order_line_record"}
if definition.family == "cad_file":
return {"cad_file_record"}
return set()
def _coerce_node_label(node: "WorkflowNode") -> str:
return f"{node.id!r} ({node.step.value})"
def _validate_param_value(
*,
node: "WorkflowNode",
field_definition: WorkflowNodeFieldDefinition,
value: Any,
) -> None:
if value is None:
return
field_label = f"node {_coerce_node_label(node)} param {field_definition.key!r}"
if field_definition.type == "number":
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError(f"{field_label} must be a number")
numeric_value = float(value)
if field_definition.min is not None and numeric_value < field_definition.min:
raise ValueError(f"{field_label} must be >= {field_definition.min:g}")
if field_definition.max is not None and numeric_value > field_definition.max:
raise ValueError(f"{field_label} must be <= {field_definition.max:g}")
return
if field_definition.type == "boolean":
if not isinstance(value, bool):
raise ValueError(f"{field_label} must be a boolean")
return
if field_definition.type == "select":
valid_values = {option.value for option in field_definition.options}
if value not in valid_values:
allowed_values = ", ".join(repr(option) for option in sorted(valid_values, key=repr))
raise ValueError(f"{field_label} must be one of: {allowed_values}")
class WorkflowPosition(BaseModel): class WorkflowPosition(BaseModel):
@@ -37,7 +88,7 @@ class WorkflowNodeUI(BaseModel):
class WorkflowNode(BaseModel): class WorkflowNode(BaseModel):
id: str id: str
step: StepName # validated against the StepName StrEnum step: StepName # validated against the StepName StrEnum
params: dict = {} params: dict[str, Any] = Field(default_factory=dict)
ui: WorkflowNodeUI | None = None ui: WorkflowNodeUI | None = None
@@ -52,12 +103,13 @@ class WorkflowEdge(BaseModel):
class WorkflowUI(BaseModel): class WorkflowUI(BaseModel):
preset: str | None = None preset: str | None = None
execution_mode: Literal["legacy", "graph", "shadow"] | None = None execution_mode: Literal["legacy", "graph", "shadow"] | None = None
family: Literal["cad_file", "order_line", "mixed"] | None = None
class WorkflowConfig(BaseModel): class WorkflowConfig(BaseModel):
version: int = 1 version: int = 1
nodes: list[WorkflowNode] nodes: list[WorkflowNode]
edges: list[WorkflowEdge] = [] edges: list[WorkflowEdge] = Field(default_factory=list)
ui: WorkflowUI | None = None ui: WorkflowUI | None = None
@field_validator("nodes") @field_validator("nodes")
@@ -93,3 +145,145 @@ class WorkflowConfig(BaseModel):
raise ValueError(f"duplicate node id: {node.id!r}") raise ValueError(f"duplicate node id: {node.id!r}")
seen.add(node.id) seen.add(node.id)
return self return self
@model_validator(mode="after")
def node_params_match_registry(self) -> "WorkflowConfig":
for node in self.nodes:
definition = get_node_definition(node.step)
if definition is None:
continue
field_definitions = {field.key: field for field in definition.fields}
allowed_keys = {field.key for field in definition.fields}
unknown_keys = sorted(key for key in node.params if key not in allowed_keys)
if unknown_keys:
joined = ", ".join(repr(key) for key in unknown_keys)
raise ValueError(
f"node {node.id!r} ({node.step.value}) uses unknown param key(s): {joined}"
)
for key, value in node.params.items():
field_definition = field_definitions.get(key)
if field_definition is None:
continue
_validate_param_value(
node=node,
field_definition=field_definition,
value=value,
)
return self
@model_validator(mode="after")
def ui_family_matches_node_families(self) -> "WorkflowConfig":
families = {
definition.family
for node in self.nodes
if (definition := get_node_definition(node.step)) is not None
}
if not families:
return self
inferred_family = "mixed" if len(families) > 1 else next(iter(families))
execution_mode = self.ui.execution_mode if self.ui is not None else "legacy"
if execution_mode in {"graph", "shadow"} and inferred_family == "mixed":
raise ValueError(
"workflow ui.execution_mode must stay single-family for graph/shadow execution"
)
if self.ui is None or self.ui.family is None:
return self
if self.ui.family != inferred_family:
ordered_families = ", ".join(sorted(families))
raise ValueError(
f"workflow ui.family={self.ui.family!r} does not match node families: {ordered_families}"
)
return self
@model_validator(mode="after")
def node_contracts_are_connected(self) -> "WorkflowConfig":
execution_mode = self.ui.execution_mode if self.ui is not None else "legacy"
if execution_mode not in {"graph", "shadow"}:
return self
node_by_id = {node.id: node for node in self.nodes}
adjacency: dict[str, list[str]] = {node.id: [] for node in self.nodes}
in_degree: dict[str, int] = {node.id: 0 for node in self.nodes}
available_artifacts: dict[str, set[str]] = {node.id: set() for node in self.nodes}
for edge in self.edges:
adjacency[edge.from_node].append(edge.to_node)
in_degree[edge.to_node] += 1
queue: deque[str] = deque(
node_id for node_id, degree in in_degree.items() if degree == 0
)
processed = 0
while queue:
node_id = queue.popleft()
processed += 1
node = node_by_id[node_id]
definition = get_node_definition(node.step)
if definition is None:
continue
node_inputs = available_artifacts[node_id] | _context_seed_artifacts(definition)
required = set(definition.input_contract.get("requires", []))
missing_required = sorted(required - node_inputs)
if missing_required:
joined = ", ".join(repr(value) for value in missing_required)
raise ValueError(
f"node {_coerce_node_label(node)} is missing required input artifact(s): {joined}"
)
required_any = set(definition.input_contract.get("requires_any", []))
if required_any and not node_inputs.intersection(required_any):
joined = ", ".join(repr(value) for value in sorted(required_any))
raise ValueError(
f"node {_coerce_node_label(node)} requires at least one upstream artifact from: {joined}"
)
node_outputs = node_inputs | set(definition.output_contract.get("provides", []))
for downstream_id in adjacency[node_id]:
available_artifacts[downstream_id].update(node_outputs)
in_degree[downstream_id] -= 1
if in_degree[downstream_id] == 0:
queue.append(downstream_id)
if processed != len(self.nodes):
return self
return self
@model_validator(mode="after")
def edges_are_unique_and_acyclic(self) -> "WorkflowConfig":
edge_pairs: set[tuple[str, str]] = set()
adjacency: dict[str, list[str]] = {node.id: [] for node in self.nodes}
in_degree: dict[str, int] = {node.id: 0 for node in self.nodes}
for edge in self.edges:
edge_pair = (edge.from_node, edge.to_node)
if edge.from_node == edge.to_node:
raise ValueError(f"self-referential edge is not allowed: {edge.from_node!r}")
if edge_pair in edge_pairs:
raise ValueError(
f"duplicate edge is not allowed: {edge.from_node!r} -> {edge.to_node!r}"
)
edge_pairs.add(edge_pair)
adjacency[edge.from_node].append(edge.to_node)
in_degree[edge.to_node] += 1
queue = [node_id for node_id, degree in in_degree.items() if degree == 0]
processed = 0
while queue:
node_id = queue.pop(0)
processed += 1
for neighbor in adjacency[node_id]:
in_degree[neighbor] -= 1
if in_degree[neighbor] == 0:
queue.append(neighbor)
if processed != len(self.nodes):
raise ValueError(
"workflow graph must be acyclic"
)
return self
@@ -1,6 +1,7 @@
import pytest import pytest
from app.core.process_steps import StepName from app.core.process_steps import StepName
from app.domains.rendering.models import WorkflowDefinition
from app.domains.rendering.workflow_node_registry import ( from app.domains.rendering.workflow_node_registry import (
get_node_definition, get_node_definition,
list_node_definitions, list_node_definitions,
@@ -8,30 +9,99 @@ from app.domains.rendering.workflow_node_registry import (
def test_node_registry_covers_all_step_names(): def test_node_registry_covers_all_step_names():
registered_steps = {definition.step for definition in list_node_definitions()} definitions = list_node_definitions()
registered_steps = {definition.step for definition in definitions}
expected_steps = {step.value for step in StepName} expected_steps = {step.value for step in StepName}
assert registered_steps == expected_steps assert registered_steps == expected_steps
assert all(definition.family in {"cad_file", "order_line"} for definition in definitions)
assert all(definition.module_key for definition in definitions)
assert all(definition.legacy_source for definition in definitions)
def test_turntable_node_definition_exposes_expected_schema(): def test_turntable_node_definition_exposes_expected_schema():
definition = get_node_definition(StepName.BLENDER_TURNTABLE) definition = get_node_definition(StepName.BLENDER_TURNTABLE)
assert definition is not None assert definition is not None
assert definition.family == "order_line"
assert definition.module_key == "render.production.turntable"
assert definition.node_type == "renderFramesNode" assert definition.node_type == "renderFramesNode"
assert definition.defaults["fps"] == 24 assert definition.defaults["fps"] == 24
assert definition.defaults["duration_s"] == 5 assert definition.defaults["duration_s"] == 5
assert definition.input_contract["context"] == "order_line"
assert definition.output_contract["provides"] == ["rendered_frames", "rendered_video"]
assert "material_assignments" in definition.artifact_roles_consumed
assert "rendered_video" in definition.artifact_roles_produced
assert definition.legacy_source == f"legacy_step:{StepName.BLENDER_TURNTABLE.value}"
assert {field.key for field in definition.fields} >= { assert {field.key for field in definition.fields} >= {
"render_engine", "render_engine",
"cycles_device",
"samples", "samples",
"transparent_bg",
"bg_color",
"width", "width",
"height", "height",
"fps", "fps",
"duration_s", "duration_s",
"turntable_degrees",
"turntable_axis",
"camera_orbit",
"rotation_x",
"rotation_y",
"rotation_z", "rotation_z",
"focal_length_mm",
"sensor_width_mm",
"material_override",
} }
def test_order_line_setup_and_template_contracts_expose_runtime_outputs():
setup = get_node_definition(StepName.ORDER_LINE_SETUP)
template = get_node_definition(StepName.RESOLVE_TEMPLATE)
bbox = get_node_definition(StepName.GLB_BBOX)
output = get_node_definition(StepName.OUTPUT_SAVE)
export_blend = get_node_definition(StepName.EXPORT_BLEND)
notify = get_node_definition(StepName.NOTIFY)
assert setup is not None
assert template is not None
assert bbox is not None
assert output is not None
assert export_blend is not None
assert notify is not None
assert set(setup.output_contract["provides"]) >= {
"order_line_context",
"cad_file_ref",
"step_path",
"cad_materials",
"glb_preview",
"bbox",
}
assert set(template.output_contract["provides"]) >= {
"render_template",
"output_profile",
"template_path",
"material_library",
"material_map",
"use_materials",
"override_material",
}
assert {field.key for field in bbox.fields} == {"glb_path"}
assert output.input_contract["requires"] == ["order_line_context"]
assert output.input_contract["requires_any"] == ["rendered_image", "rendered_frames", "rendered_video"]
assert set(output.output_contract["provides"]) >= {"media_asset", "workflow_result"}
assert export_blend.defaults["output_name_suffix"] == ""
assert {field.key for field in export_blend.fields} == {"output_name_suffix"}
assert notify.input_contract["requires"] == ["order_line_context"]
assert notify.input_contract["requires_any"] == [
"rendered_image",
"rendered_frames",
"rendered_video",
"workflow_result",
]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_node_definitions_endpoint_returns_registry(client, auth_headers): async def test_node_definitions_endpoint_returns_registry(client, auth_headers):
response = await client.get("/api/workflows/node-definitions", headers=auth_headers) response = await client.get("/api/workflows/node-definitions", headers=auth_headers)
@@ -43,8 +113,57 @@ async def test_node_definitions_endpoint_returns_registry(client, auth_headers):
blender_still = next( blender_still = next(
definition for definition in body["definitions"] if definition["step"] == StepName.BLENDER_STILL.value definition for definition in body["definitions"] if definition["step"] == StepName.BLENDER_STILL.value
) )
assert blender_still["family"] == "order_line"
assert blender_still["module_key"] == "render.production.still"
assert blender_still["node_type"] == "renderNode" assert blender_still["node_type"] == "renderNode"
assert blender_still["defaults"]["render_engine"] == "cycles" assert blender_still["defaults"]["use_custom_render_settings"] is False
assert blender_still["input_contract"]["context"] == "order_line"
assert "bbox" in blender_still["artifact_roles_consumed"]
assert blender_still["output_contract"]["provides"] == ["rendered_image"]
assert blender_still["legacy_source"] == f"legacy_step:{StepName.BLENDER_STILL.value}"
assert {field["key"] for field in blender_still["fields"]} >= {
"use_custom_render_settings",
"render_engine",
"cycles_device",
"samples",
"width",
"height",
"transparent_bg",
"noise_threshold",
"denoiser",
"denoising_input_passes",
"denoising_prefilter",
"denoising_quality",
"denoising_use_gpu",
"target_collection",
"lighting_only",
"shadow_catcher",
"rotation_x",
"rotation_y",
"rotation_z",
"focal_length_mm",
"sensor_width_mm",
"material_override",
}
glb_bbox = next(
definition for definition in body["definitions"] if definition["step"] == StepName.GLB_BBOX.value
)
assert glb_bbox["fields"] == [
{
"key": "glb_path",
"label": "GLB Path Override",
"type": "text",
"description": "Optional absolute path to a specific GLB file. Leave empty to reuse the prepared preview/export artifact automatically.",
"section": "Inputs",
"default": "",
"min": None,
"max": None,
"step": None,
"unit": None,
"options": [],
}
]
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -82,3 +201,52 @@ async def test_workflow_crud_roundtrip_preserves_execution_mode(client, auth_hea
assert get_response.status_code == 200 assert get_response.status_code == 200
fetched = get_response.json() fetched = get_response.json()
assert fetched["config"]["ui"]["execution_mode"] == "shadow" assert fetched["config"]["ui"]["execution_mode"] == "shadow"
@pytest.mark.asyncio
async def test_admin_backfill_workflows_rewrites_legacy_configs(client, db, auth_headers):
legacy = WorkflowDefinition(
name="Legacy Still Workflow",
config={
"type": "still",
"params": {"width": 1280, "height": 720},
"ui": {"execution_mode": "graph"},
},
is_active=True,
)
canonical = WorkflowDefinition(
name="Canonical Workflow",
config={
"version": 1,
"ui": {"preset": "custom", "execution_mode": "legacy"},
"nodes": [
{
"id": "setup",
"step": StepName.ORDER_LINE_SETUP.value,
"params": {},
}
],
"edges": [],
},
is_active=True,
)
db.add_all([legacy, canonical])
await db.commit()
response = await client.post("/api/admin/settings/backfill-workflows", headers=auth_headers)
assert response.status_code == 200
body = response.json()
assert body["scanned"] == 2
assert body["updated"] == 1
assert body["invalid"] == []
assert body["workflows"] == [{"id": str(legacy.id), "name": "Legacy Still Workflow"}]
await db.refresh(legacy)
await db.refresh(canonical)
assert legacy.config["version"] == 1
assert legacy.config["ui"]["preset"] == "still"
assert legacy.config["ui"]["execution_mode"] == "graph"
assert any(node["step"] == StepName.BLENDER_STILL.value for node in legacy.config["nodes"])
assert canonical.config["ui"]["preset"] == "custom"
@@ -0,0 +1,241 @@
import pytest
from pydantic import ValidationError
from app.domains.rendering.workflow_schema import WorkflowConfig
def test_workflow_schema_rejects_duplicate_edges():
with pytest.raises(ValidationError, match="duplicate edge is not allowed"):
WorkflowConfig.model_validate(
{
"version": 1,
"nodes": [
{"id": "setup", "step": "order_line_setup", "params": {}},
{"id": "render", "step": "blender_still", "params": {}},
],
"edges": [
{"from": "setup", "to": "render"},
{"from": "setup", "to": "render"},
],
}
)
def test_workflow_schema_rejects_self_referential_edges():
with pytest.raises(ValidationError, match="self-referential edge is not allowed"):
WorkflowConfig.model_validate(
{
"version": 1,
"nodes": [
{"id": "render", "step": "blender_still", "params": {}},
],
"edges": [
{"from": "render", "to": "render"},
],
}
)
def test_workflow_schema_rejects_cycles():
with pytest.raises(ValidationError, match="workflow graph must be acyclic"):
WorkflowConfig.model_validate(
{
"version": 1,
"nodes": [
{"id": "setup", "step": "order_line_setup", "params": {}},
{"id": "template", "step": "resolve_template", "params": {}},
{"id": "render", "step": "blender_still", "params": {}},
],
"edges": [
{"from": "setup", "to": "template"},
{"from": "template", "to": "render"},
{"from": "render", "to": "setup"},
],
}
)
def test_workflow_schema_rejects_unknown_node_params():
with pytest.raises(ValidationError, match="uses unknown param key"):
WorkflowConfig.model_validate(
{
"version": 1,
"nodes": [
{
"id": "bbox",
"step": "glb_bbox",
"params": {"glb_path": "/tmp/model.glb", "bogus": "value"},
},
],
"edges": [],
}
)
def test_workflow_schema_accepts_known_node_params():
config = WorkflowConfig.model_validate(
{
"version": 1,
"nodes": [
{
"id": "bbox",
"step": "glb_bbox",
"params": {"glb_path": "/tmp/model.glb"},
},
],
"edges": [],
"ui": {"family": "order_line"},
}
)
assert config.ui is not None
assert config.ui.family == "order_line"
def test_workflow_schema_rejects_ui_family_mismatch():
with pytest.raises(ValidationError, match="ui.family"):
WorkflowConfig.model_validate(
{
"version": 1,
"nodes": [
{"id": "setup", "step": "order_line_setup", "params": {}},
{"id": "render", "step": "blender_still", "params": {}},
],
"edges": [
{"from": "setup", "to": "render"},
],
"ui": {"family": "cad_file"},
}
)
def test_workflow_schema_accepts_explicit_mixed_family_when_declared():
config = WorkflowConfig.model_validate(
{
"version": 1,
"nodes": [
{"id": "cad", "step": "resolve_step_path", "params": {}},
{"id": "render", "step": "blender_still", "params": {}},
],
"edges": [],
"ui": {"family": "mixed"},
}
)
assert config.ui is not None
assert config.ui.family == "mixed"
def test_workflow_schema_rejects_invalid_select_value():
with pytest.raises(ValidationError, match="must be one of"):
WorkflowConfig.model_validate(
{
"version": 1,
"nodes": [
{
"id": "notify",
"step": "notify",
"params": {"channel": "email"},
},
],
"edges": [],
}
)
def test_workflow_schema_rejects_invalid_number_value():
with pytest.raises(ValidationError, match="must be a number"):
WorkflowConfig.model_validate(
{
"version": 1,
"nodes": [
{
"id": "render",
"step": "blender_still",
"params": {"samples": "high"},
},
],
"edges": [],
}
)
def test_workflow_schema_rejects_invalid_boolean_value():
with pytest.raises(ValidationError, match="must be a boolean"):
WorkflowConfig.model_validate(
{
"version": 1,
"nodes": [
{
"id": "render",
"step": "blender_still",
"params": {"use_custom_render_settings": "yes"},
},
],
"edges": [],
}
)
def test_workflow_schema_rejects_missing_required_upstream_artifact():
with pytest.raises(ValidationError, match="missing required input artifact"):
WorkflowConfig.model_validate(
{
"version": 1,
"nodes": [
{"id": "setup", "step": "order_line_setup", "params": {}},
{"id": "render", "step": "blender_still", "params": {}},
],
"edges": [
{"from": "setup", "to": "render"},
],
"ui": {"execution_mode": "graph", "family": "order_line"},
}
)
def test_workflow_schema_accepts_transitive_contract_wiring():
config = WorkflowConfig.model_validate(
{
"version": 1,
"nodes": [
{"id": "setup", "step": "order_line_setup", "params": {}},
{"id": "template", "step": "resolve_template", "params": {}},
{"id": "populate_materials", "step": "auto_populate_materials", "params": {}},
{"id": "bbox", "step": "glb_bbox", "params": {}},
{"id": "resolve_materials", "step": "material_map_resolve", "params": {}},
{"id": "render", "step": "blender_still", "params": {}},
{"id": "output", "step": "output_save", "params": {}},
],
"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": "template", "to": "render"},
{"from": "bbox", "to": "render"},
{"from": "resolve_materials", "to": "render"},
{"from": "render", "to": "output"},
],
"ui": {"family": "order_line", "execution_mode": "graph"},
}
)
assert config.ui is not None
assert config.ui.execution_mode == "graph"
def test_workflow_schema_rejects_mixed_family_graph_execution():
with pytest.raises(ValidationError, match="single-family"):
WorkflowConfig.model_validate(
{
"version": 1,
"nodes": [
{"id": "cad", "step": "resolve_step_path", "params": {}},
{"id": "render", "step": "blender_still", "params": {}},
],
"edges": [],
"ui": {"execution_mode": "graph", "family": "mixed"},
}
)
+36 -2
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from 'vitest' import { describe, expect, test } from 'vitest'
import { createPresetWorkflowConfig } from '../../api/workflows' import { createPresetWorkflowConfig, createStarterWorkflowConfig, normalizeWorkflowConfig } from '../../api/workflows'
describe('workflow preset config builders', () => { describe('workflow preset config builders', () => {
test('builds a non-legacy still graph preset', () => { test('builds a non-legacy still graph preset', () => {
@@ -12,20 +12,54 @@ describe('workflow preset config builders', () => {
expect(config.ui?.preset).toBe('still_graph') expect(config.ui?.preset).toBe('still_graph')
expect(config.ui?.execution_mode).toBe('graph') expect(config.ui?.execution_mode).toBe('graph')
expect(config.ui?.family).toBe('order_line')
expect(config.nodes.map(node => node.step)).toEqual([ expect(config.nodes.map(node => node.step)).toEqual([
'order_line_setup', 'order_line_setup',
'auto_populate_materials',
'resolve_template', 'resolve_template',
'auto_populate_materials',
'glb_bbox',
'material_map_resolve', 'material_map_resolve',
'blender_still', 'blender_still',
'output_save', 'output_save',
'notify', 'notify',
]) ])
expect(config.nodes.find(node => node.step === 'blender_still')?.params).toMatchObject({ expect(config.nodes.find(node => node.step === 'blender_still')?.params).toMatchObject({
use_custom_render_settings: true,
render_engine: 'cycles', render_engine: 'cycles',
samples: 128, samples: 128,
width: 1600, width: 1600,
height: 900, height: 900,
}) })
}) })
test('builds family-specific starter configs', () => {
const cadStarter = createStarterWorkflowConfig('cad_file')
const orderStarter = createStarterWorkflowConfig('order_line')
expect(cadStarter.ui?.blueprint).toBe('starter_cad_intake')
expect(cadStarter.ui?.family).toBe('cad_file')
expect(cadStarter.nodes.map(node => node.step)).toEqual(['resolve_step_path'])
expect(orderStarter.ui?.blueprint).toBe('starter_order_rendering')
expect(orderStarter.ui?.family).toBe('order_line')
expect(orderStarter.nodes.map(node => node.step)).toEqual(['order_line_setup'])
})
test('preserves ui.family during normalization', () => {
const config = normalizeWorkflowConfig({
version: 1,
ui: {
preset: 'custom',
execution_mode: 'shadow',
family: 'order_line',
},
nodes: [
{ id: 'setup', step: 'order_line_setup', params: {} },
],
edges: [],
})
expect(config.ui?.family).toBe('order_line')
expect(config.ui?.execution_mode).toBe('shadow')
})
}) })
+151 -39
View File
@@ -2,12 +2,14 @@ import api from './client'
export type WorkflowPresetType = 'still' | 'still_graph' | 'turntable' | 'multi_angle' | 'still_with_exports' | 'custom' export type WorkflowPresetType = 'still' | 'still_graph' | 'turntable' | 'multi_angle' | 'still_with_exports' | 'custom'
export type WorkflowExecutionMode = 'legacy' | 'graph' | 'shadow' export type WorkflowExecutionMode = 'legacy' | 'graph' | 'shadow'
export type WorkflowStarterFamily = 'cad_file' | 'order_line'
export interface WorkflowDefinition { export interface WorkflowDefinition {
id: string id: string
name: string name: string
output_type_id: string | null output_type_id: string | null
config: WorkflowConfig config: WorkflowConfig
family: WorkflowNodeFamily | 'mixed' | null
is_active: boolean is_active: boolean
created_at: string created_at: string
} }
@@ -54,6 +56,7 @@ export interface WorkflowEdge {
export interface WorkflowUi { export interface WorkflowUi {
preset?: WorkflowPresetType preset?: WorkflowPresetType
execution_mode?: WorkflowExecutionMode execution_mode?: WorkflowExecutionMode
family?: WorkflowNodeFamily | 'mixed'
blueprint?: string blueprint?: string
} }
@@ -234,9 +237,13 @@ export interface WorkflowNodeFieldDefinition {
options: WorkflowNodeFieldOption[] options: WorkflowNodeFieldOption[]
} }
export type WorkflowNodeFamily = 'cad_file' | 'order_line'
export interface WorkflowNodeDefinition { export interface WorkflowNodeDefinition {
step: string step: string
label: string label: string
family: WorkflowNodeFamily
module_key: string
category: StepCategory category: StepCategory
description: string description: string
node_type: string node_type: string
@@ -245,6 +252,11 @@ export interface WorkflowNodeDefinition {
fields: WorkflowNodeFieldDefinition[] fields: WorkflowNodeFieldDefinition[]
execution_kind: WorkflowNodeExecutionKind execution_kind: WorkflowNodeExecutionKind
legacy_compatible: boolean 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 { export interface WorkflowNodeDefinitionsResponse {
@@ -268,6 +280,48 @@ export const getNodeDefinitions = (): Promise<WorkflowNodeDefinitionsResponse> =
export const getPipelineSteps = (): Promise<PipelineStepsResponse> => export const getPipelineSteps = (): Promise<PipelineStepsResponse> =>
api.get('/workflows/pipeline-steps').then(r => r.data) api.get('/workflows/pipeline-steps').then(r => r.data)
function buildStillGraphNodes(renderParams: WorkflowParams): { nodes: WorkflowNode[]; edges: WorkflowEdge[] } {
return {
nodes: [
{ id: 'setup', step: 'order_line_setup', params: {}, ui: { label: 'Order Line Setup', position: { x: 0, y: 160 } } },
{ id: 'template', step: 'resolve_template', params: {}, ui: { label: 'Resolve Template', position: { x: 220, y: 160 } } },
{
id: 'populate_materials',
step: 'auto_populate_materials',
params: {},
ui: { type: 'processNode', label: 'Auto Populate Materials', position: { x: 220, y: 320 } },
},
{ id: 'bbox', step: 'glb_bbox', params: {}, ui: { type: 'processNode', label: 'Compute Bounding Box', position: { x: 220, y: 40 } } },
{
id: 'resolve_materials',
step: 'material_map_resolve',
params: {},
ui: { type: 'processNode', label: 'Resolve Material Map', position: { x: 440, y: 200 } },
},
{
id: 'render',
step: 'blender_still',
params: { use_custom_render_settings: true, ...renderParams },
ui: { type: 'renderNode', label: 'Still Render', position: { x: 680, y: 160 } },
},
{ id: 'output', step: 'output_save', params: {}, ui: { type: 'outputNode', label: 'Save Output', position: { x: 920, y: 120 } } },
{ id: 'notify', step: 'notify', params: {}, ui: { type: 'outputNode', label: 'Notify Result', position: { x: 920, y: 220 } } },
],
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: 'render' },
{ from: 'bbox', to: 'render' },
{ from: 'template', to: 'render' },
{ from: 'render', to: 'output' },
{ from: 'render', to: 'notify' },
],
}
}
function migratePresetConfig(type: WorkflowPresetType, params: WorkflowParams = {}): WorkflowConfig { function migratePresetConfig(type: WorkflowPresetType, params: WorkflowParams = {}): WorkflowConfig {
const renderParams = { ...params } const renderParams = { ...params }
const resolution = Array.isArray(renderParams.resolution) ? renderParams.resolution : undefined const resolution = Array.isArray(renderParams.resolution) ? renderParams.resolution : undefined
@@ -280,7 +334,7 @@ function migratePresetConfig(type: WorkflowPresetType, params: WorkflowParams =
if (type === 'still') { if (type === 'still') {
return { return {
version: 1, version: 1,
ui: { preset: type, execution_mode: 'legacy' }, ui: { preset: type, execution_mode: 'legacy', family: 'order_line' },
nodes: [ nodes: [
{ id: 'setup', step: 'order_line_setup', params: {}, ui: { label: 'Order Line Setup', position: { x: 0, y: 100 } } }, { id: 'setup', step: 'order_line_setup', params: {}, ui: { label: 'Order Line Setup', position: { x: 0, y: 100 } } },
{ id: 'template', step: 'resolve_template', params: {}, ui: { label: 'Resolve Template', position: { x: 220, y: 100 } } }, { id: 'template', step: 'resolve_template', params: {}, ui: { label: 'Resolve Template', position: { x: 220, y: 100 } } },
@@ -296,43 +350,19 @@ function migratePresetConfig(type: WorkflowPresetType, params: WorkflowParams =
} }
if (type === 'still_graph') { if (type === 'still_graph') {
const { nodes, edges } = buildStillGraphNodes(renderParams)
return { return {
version: 1, version: 1,
ui: { preset: type, execution_mode: 'graph' }, ui: { preset: type, execution_mode: 'graph', family: 'order_line' },
nodes: [ nodes,
{ id: 'setup', step: 'order_line_setup', params: {}, ui: { label: 'Order Line Setup', position: { x: 0, y: 100 } } }, edges,
{
id: 'populate_materials',
step: 'auto_populate_materials',
params: {},
ui: { type: 'processNode', label: 'Auto Populate Materials', position: { x: 220, y: 100 } },
},
{ id: 'template', step: 'resolve_template', params: {}, ui: { label: 'Resolve Template', position: { x: 440, y: 100 } } },
{
id: 'resolve_materials',
step: 'material_map_resolve',
params: {},
ui: { type: 'processNode', label: 'Resolve Material Map', position: { x: 660, y: 100 } },
},
{ id: 'render', step: 'blender_still', params: renderParams, ui: { type: 'renderNode', label: 'Still Render', position: { x: 880, y: 100 } } },
{ id: 'output', step: 'output_save', params: {}, ui: { type: 'outputNode', label: 'Save Output', position: { x: 1100, y: 70 } } },
{ id: 'notify', step: 'notify', params: {}, ui: { type: 'outputNode', label: 'Notify Result', position: { x: 1100, y: 160 } } },
],
edges: [
{ from: 'setup', to: 'populate_materials' },
{ from: 'populate_materials', to: 'template' },
{ from: 'template', to: 'resolve_materials' },
{ from: 'resolve_materials', to: 'render' },
{ from: 'render', to: 'output' },
{ from: 'render', to: 'notify' },
],
} }
} }
if (type === 'turntable') { if (type === 'turntable') {
return { return {
version: 1, version: 1,
ui: { preset: type, execution_mode: 'legacy' }, ui: { preset: type, execution_mode: 'legacy', family: 'order_line' },
nodes: [ nodes: [
{ id: 'setup', step: 'order_line_setup', params: {}, ui: { label: 'Order Line Setup', position: { x: 0, y: 100 } } }, { id: 'setup', step: 'order_line_setup', params: {}, ui: { label: 'Order Line Setup', position: { x: 0, y: 100 } } },
{ id: 'template', step: 'resolve_template', params: {}, ui: { label: 'Resolve Template', position: { x: 220, y: 100 } } }, { id: 'template', step: 'resolve_template', params: {}, ui: { label: 'Resolve Template', position: { x: 220, y: 100 } } },
@@ -353,7 +383,7 @@ function migratePresetConfig(type: WorkflowPresetType, params: WorkflowParams =
delete sharedParams.angles delete sharedParams.angles
return { return {
version: 1, version: 1,
ui: { preset: type, execution_mode: 'legacy' }, ui: { preset: type, execution_mode: 'legacy', family: 'order_line' },
nodes: [ nodes: [
{ id: 'setup', step: 'order_line_setup', params: {}, ui: { label: 'Order Line Setup', position: { x: 0, y: 195 } } }, { id: 'setup', step: 'order_line_setup', params: {}, ui: { label: 'Order Line Setup', position: { x: 0, y: 195 } } },
{ id: 'template', step: 'resolve_template', params: {}, ui: { label: 'Resolve Template', position: { x: 220, y: 195 } } }, { id: 'template', step: 'resolve_template', params: {}, ui: { label: 'Resolve Template', position: { x: 220, y: 195 } } },
@@ -376,7 +406,7 @@ function migratePresetConfig(type: WorkflowPresetType, params: WorkflowParams =
if (type === 'still_with_exports') { if (type === 'still_with_exports') {
return { return {
version: 1, version: 1,
ui: { preset: type, execution_mode: 'legacy' }, ui: { preset: type, execution_mode: 'legacy', family: 'order_line' },
nodes: [ nodes: [
{ id: 'setup', step: 'order_line_setup', params: {}, ui: { label: 'Order Line Setup', position: { x: 0, y: 100 } } }, { id: 'setup', step: 'order_line_setup', params: {}, ui: { label: 'Order Line Setup', position: { x: 0, y: 100 } } },
{ id: 'template', step: 'resolve_template', params: {}, ui: { label: 'Resolve Template', position: { x: 220, y: 100 } } }, { id: 'template', step: 'resolve_template', params: {}, ui: { label: 'Resolve Template', position: { x: 220, y: 100 } } },
@@ -395,7 +425,7 @@ function migratePresetConfig(type: WorkflowPresetType, params: WorkflowParams =
return { return {
version: 1, version: 1,
ui: { preset: 'custom', execution_mode: 'legacy' }, ui: { preset: 'custom', execution_mode: 'legacy', family: 'order_line' },
nodes: [ nodes: [
{ {
id: 'setup', id: 'setup',
@@ -409,25 +439,30 @@ function migratePresetConfig(type: WorkflowPresetType, params: WorkflowParams =
} }
function normalizeWorkflowDefinition(raw: WorkflowDefinition): WorkflowDefinition { function normalizeWorkflowDefinition(raw: WorkflowDefinition): WorkflowDefinition {
const config = normalizeWorkflowConfig(raw.config as unknown as Record<string, unknown>)
return { return {
...raw, ...raw,
config: normalizeWorkflowConfig(raw.config as unknown as Record<string, unknown>), family: raw.family ?? inferWorkflowFamily(config),
config,
} }
} }
export function normalizeWorkflowConfig(raw: Record<string, unknown>): WorkflowConfig { export function normalizeWorkflowConfig(raw: Record<string, unknown>): WorkflowConfig {
if ('version' in raw && Array.isArray(raw.nodes)) { if ('version' in raw && Array.isArray(raw.nodes)) {
const rawUi = (raw.ui as WorkflowUi | undefined) ?? {} 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[]) : []
return { return {
version: Number(raw.version ?? 1), version: Number(raw.version ?? 1),
nodes: (raw.nodes as WorkflowNode[]).map(node => ({ nodes,
...node, edges,
params: { ...(node.params ?? {}) },
})),
edges: Array.isArray(raw.edges) ? (raw.edges as WorkflowEdge[]) : [],
ui: { ui: {
...rawUi, ...rawUi,
execution_mode: rawUi.execution_mode ?? 'legacy', execution_mode: rawUi.execution_mode ?? 'legacy',
family: rawUi.family ?? inferWorkflowFamily({ version: Number(raw.version ?? 1), nodes, edges }),
}, },
} }
} }
@@ -448,6 +483,83 @@ export function createPresetWorkflowConfig(type: WorkflowPresetType, params: Wor
return migratePresetConfig(type, params) return migratePresetConfig(type, params)
} }
export function createStarterWorkflowConfig(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: [
{
id: 'resolve_step',
step: 'resolve_step_path',
params: {},
ui: { type: 'inputNode', label: 'Resolve STEP Path', position: { x: 120, y: 140 } },
},
],
edges: [],
}
}
return {
version: 1,
ui: {
preset: 'custom',
execution_mode: 'legacy',
family: 'order_line',
blueprint: 'starter_order_rendering',
},
nodes: [
{
id: 'setup',
step: 'order_line_setup',
params: {},
ui: { type: 'processNode', label: 'Order Line Setup', position: { x: 120, y: 140 } },
},
],
edges: [],
}
}
export function getWorkflowPresetType(config: WorkflowConfig): WorkflowPresetType { export function getWorkflowPresetType(config: WorkflowConfig): WorkflowPresetType {
return config.ui?.preset ?? 'custom' 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 'order_line_setup':
case 'resolve_template':
case 'material_map_resolve':
case 'auto_populate_materials':
case 'glb_bbox':
case 'blender_still':
case 'blender_turntable':
case 'output_save':
case 'export_blend':
case 'notify':
return 'order_line'
default:
return null
}
})
.filter((family): family is WorkflowNodeFamily => family !== null),
)
if (families.size === 0) return null
if (families.size > 1) return 'mixed'
return Array.from(families)[0]
}