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"}
_WORKFLOW_BLUEPRINTS = {"cad_intake", "order_rendering"}
_WORKFLOW_BLUEPRINTS = {"cad_intake", "order_rendering", "still_graph_reference"}
_WORKFLOW_STARTERS = {"cad_file", "order_line"}
_NODE_TYPE_TO_STEP: dict[str, str] = {
@@ -63,6 +63,50 @@ def _resolution_to_dimensions(params: dict[str, Any]) -> dict[str, Any]:
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(
preset_type: WorkflowPresetType,
params: dict[str, Any] | None = None,
@@ -94,31 +138,7 @@ def build_preset_workflow_config(
{"from": "render", "to": "output"},
]
elif preset_type == "still_graph":
nodes = [
_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"},
]
nodes, edges = _build_order_line_still_graph_nodes(render_params)
elif preset_type == "turntable":
nodes = [
_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": "threejs_thumb", "to": "save_threejs_thumb"},
]
else:
elif blueprint == "order_rendering":
nodes = [
_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("resolve_materials", StepName.MATERIAL_MAP_RESOLVE, 440, 80, 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("resolve_materials", StepName.MATERIAL_MAP_RESOLVE, 440, 220, label="Resolve Material Map"),
_make_node(
"still_render",
StepName.BLENDER_STILL,
1120,
680,
80,
params={"rotation_z": 0},
node_type="renderNode",
@@ -265,33 +285,42 @@ def build_workflow_blueprint_config(blueprint: str) -> dict[str, Any]:
_make_node(
"turntable_render",
StepName.BLENDER_TURNTABLE,
1120,
680,
220,
params={"fps": 24, "duration_s": 5},
node_type="renderFramesNode",
label="Render Turntable",
),
_make_node("blend_export", StepName.EXPORT_BLEND, 1120, 360, label="Export Blend"),
_make_node("save_still", StepName.OUTPUT_SAVE, 1360, 80, label="Save Still Output"),
_make_node("save_turntable", StepName.OUTPUT_SAVE, 1360, 220, label="Save Turntable Output"),
_make_node("notify_still", StepName.NOTIFY, 1600, 80, label="Notify Still Result"),
_make_node("notify_turntable", StepName.NOTIFY, 1600, 220, label="Notify Turntable Result"),
_make_node("notify_export", StepName.NOTIFY, 1360, 360, label="Notify Blend Export"),
_make_node("blend_export", StepName.EXPORT_BLEND, 680, 360, label="Export Blend"),
_make_node("save_still", StepName.OUTPUT_SAVE, 920, 80, label="Save Still Output"),
_make_node("save_turntable", StepName.OUTPUT_SAVE, 920, 220, label="Save Turntable Output"),
_make_node("notify_still", StepName.NOTIFY, 920, 140, label="Notify Still Result"),
_make_node("notify_turntable", StepName.NOTIFY, 920, 280, label="Notify Turntable Result"),
_make_node("notify_export", StepName.NOTIFY, 920, 360, label="Notify Blend Export"),
]
edges = [
{"from": "setup", "to": "template"},
{"from": "setup", "to": "populate_materials"},
{"from": "setup", "to": "bbox"},
{"from": "bbox", "to": "resolve_materials"},
{"from": "resolve_materials", "to": "populate_materials"},
{"from": "populate_materials", "to": "template"},
{"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": "save_still", "to": "notify_still"},
{"from": "save_turntable", "to": "notify_turntable"},
{"from": "turntable_render", "to": "notify_turntable"},
{"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 {
"version": 1,
@@ -299,7 +328,7 @@ def build_workflow_blueprint_config(blueprint: str) -> dict[str, Any]:
"edges": edges,
"ui": {
"preset": "custom",
"execution_mode": "legacy",
"execution_mode": "graph" if blueprint == "still_graph_reference" else "legacy",
"blueprint": blueprint,
},
}
@@ -438,6 +467,26 @@ def canonicalize_workflow_config(raw: dict[str, Any]) -> dict[str, Any]:
ui = {}
normalized["ui"] = dict(ui)
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
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)
if merged_ui.get("execution_mode") not in _EXECUTION_MODES:
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
return canonical
@@ -8,8 +8,9 @@ from app.core.process_steps import StepName
StepCategory = Literal["input", "processing", "rendering", "output"]
FieldType = Literal["number", "select", "boolean"]
FieldType = Literal["number", "select", "boolean", "text"]
ExecutionKind = Literal["native", "bridge"]
WorkflowNodeFamily = Literal["cad_file", "order_line"]
class WorkflowNodeFieldOption(BaseModel):
@@ -34,6 +35,8 @@ class WorkflowNodeFieldDefinition(BaseModel):
class WorkflowNodeDefinition(BaseModel):
step: str
label: str
family: WorkflowNodeFamily
module_key: str
category: StepCategory
description: str
node_type: str
@@ -42,6 +45,11 @@ class WorkflowNodeDefinition(BaseModel):
fields: list[WorkflowNodeFieldDefinition] = []
execution_kind: ExecutionKind = "native"
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(
@@ -79,6 +87,8 @@ def _field(
def _definition(
step: StepName,
label: str,
family: WorkflowNodeFamily,
module_key: str,
category: StepCategory,
description: str,
*,
@@ -87,10 +97,17 @@ def _definition(
defaults: dict[str, Any] | None = None,
fields: list[WorkflowNodeFieldDefinition] | None = None,
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:
return WorkflowNodeDefinition(
step=step.value,
label=label,
family=family,
module_key=module_key,
category=category,
description=description,
node_type=node_type,
@@ -98,67 +115,129 @@ def _definition(
defaults=defaults or {},
fields=fields or [],
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] = [
_definition(
StepName.RESOLVE_STEP_PATH,
"Resolve STEP Path",
"cad_file",
"cad.resolve_step_path",
"input",
"Locate the STEP file on disk from the CAD file record.",
node_type="inputNode",
icon="file-up",
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(
StepName.OCC_OBJECT_EXTRACT,
"Extract STEP Objects",
"cad_file",
"cad.extract_objects",
"processing",
"Extract part objects and metadata from the STEP file via OCC/cadquery.",
node_type="processNode",
icon="layers",
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(
StepName.OCC_GLB_EXPORT,
"Export GLB",
"cad_file",
"cad.export_glb",
"processing",
"Convert STEP geometry into GLB for previews and downstream rendering.",
node_type="processNode",
icon="refresh-cw",
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(
StepName.GLB_BBOX,
"Compute Bounding Box",
"order_line",
"geometry.compute_bbox",
"processing",
"Compute the model bounding box from the exported GLB for framing decisions.",
node_type="processNode",
icon="layers",
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(
StepName.MATERIAL_MAP_RESOLVE,
"Resolve Material Map",
"order_line",
"materials.resolve_map",
"processing",
"Map raw part material names to HartOMat material records via aliases.",
node_type="processNode",
icon="layers",
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(
StepName.AUTO_POPULATE_MATERIALS,
"Auto Populate Materials",
"order_line",
"materials.auto_populate",
"processing",
"Create missing material records for newly discovered part materials.",
node_type="processNode",
icon="layers",
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(
StepName.BLENDER_RENDER,
"Render Thumbnail (Blender)",
"cad_file",
"render.thumbnail.blender",
"rendering",
"Render a thumbnail image with Blender.",
node_type="renderNode",
@@ -172,7 +251,7 @@ _NODE_DEFINITIONS: list[WorkflowNodeDefinition] = [
description="Renderer backend for this Blender job.",
section="Render",
default="cycles",
options=[("cycles", "Cycles"), ("eevee", "EEVEE")],
options=_BLENDER_ENGINE_OPTIONS,
),
_field(
"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("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(
StepName.THREEJS_RENDER,
"Render Thumbnail (Three.js)",
"cad_file",
"render.thumbnail.threejs",
"rendering",
"Render a thumbnail image with the headless Three.js renderer.",
node_type="renderNode",
@@ -210,43 +295,116 @@ _NODE_DEFINITIONS: list[WorkflowNodeDefinition] = [
),
],
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(
StepName.THUMBNAIL_SAVE,
"Save Thumbnail",
"cad_file",
"media.save_thumbnail",
"output",
"Persist the generated thumbnail back onto the CAD file record.",
node_type="outputNode",
icon="download",
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(
StepName.ORDER_LINE_SETUP,
"Order Line Setup",
"order_line",
"order_line.prepare_render_context",
"processing",
"Validate order-line inputs and prepare the render job context.",
node_type="processNode",
icon="layers",
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(
StepName.RESOLVE_TEMPLATE,
"Resolve Template",
"order_line",
"rendering.resolve_template",
"processing",
"Resolve the render template for the order line and output type.",
node_type="processNode",
icon="layers",
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(
StepName.BLENDER_STILL,
"Render Still",
"order_line",
"render.production.still",
"rendering",
"Render a production still image with Blender.",
node_type="renderNode",
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=[
_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(
"render_engine",
"Render Engine",
@@ -254,7 +412,16 @@ _NODE_DEFINITIONS: list[WorkflowNodeDefinition] = [
description="Renderer backend for the still render.",
section="Render",
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(
"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("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(
"rotation_z",
"Rotation Z",
@@ -281,25 +553,71 @@ _NODE_DEFINITIONS: list[WorkflowNodeDefinition] = [
step=1,
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(
StepName.BLENDER_TURNTABLE,
"Render Turntable",
"order_line",
"render.production.turntable",
"rendering",
"Render an animated turntable sequence with Blender.",
node_type="renderFramesNode",
icon="film",
defaults={
"render_engine": "cycles",
"samples": 64,
"width": 2048,
"height": 2048,
"use_custom_render_settings": False,
"fps": 24,
"duration_s": 5,
"rotation_z": 0,
},
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(
"render_engine",
"Render Engine",
@@ -307,7 +625,16 @@ _NODE_DEFINITIONS: list[WorkflowNodeDefinition] = [
description="Renderer backend for the turntable job.",
section="Render",
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(
"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("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(
"duration_s",
@@ -335,6 +678,83 @@ _NODE_DEFINITIONS: list[WorkflowNodeDefinition] = [
step=1,
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(
"rotation_z",
"Rotation Z",
@@ -347,38 +767,112 @@ _NODE_DEFINITIONS: list[WorkflowNodeDefinition] = [
step=1,
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(
StepName.OUTPUT_SAVE,
"Save Output",
"order_line",
"media.save_output",
"output",
"Persist the rendered output file and create the media record.",
node_type="outputNode",
icon="download",
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(
StepName.EXPORT_BLEND,
"Export Blend",
"order_line",
"media.export_blend",
"output",
"Persist the generated .blend file as a downloadable media asset.",
node_type="outputNode",
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",
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(
StepName.STL_CACHE_GENERATE,
"Generate STL Cache",
"cad_file",
"cad.generate_stl_cache",
"processing",
"Generate and cache STL derivatives next to the STEP source.",
node_type="convertNode",
icon="refresh-cw",
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(
StepName.NOTIFY,
"Notify",
"order_line",
"notifications.emit",
"output",
"Emit a user-visible notification for workflow completion or failure.",
node_type="outputNode",
@@ -396,6 +890,14 @@ _NODE_DEFINITIONS: list[WorkflowNodeDefinition] = [
),
],
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.
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.
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 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):
@@ -37,7 +88,7 @@ class WorkflowNodeUI(BaseModel):
class WorkflowNode(BaseModel):
id: str
step: StepName # validated against the StepName StrEnum
params: dict = {}
params: dict[str, Any] = Field(default_factory=dict)
ui: WorkflowNodeUI | None = None
@@ -52,12 +103,13 @@ class WorkflowEdge(BaseModel):
class WorkflowUI(BaseModel):
preset: str | None = None
execution_mode: Literal["legacy", "graph", "shadow"] | None = None
family: Literal["cad_file", "order_line", "mixed"] | None = None
class WorkflowConfig(BaseModel):
version: int = 1
nodes: list[WorkflowNode]
edges: list[WorkflowEdge] = []
edges: list[WorkflowEdge] = Field(default_factory=list)
ui: WorkflowUI | None = None
@field_validator("nodes")
@@ -93,3 +145,145 @@ class WorkflowConfig(BaseModel):
raise ValueError(f"duplicate node id: {node.id!r}")
seen.add(node.id)
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
from app.core.process_steps import StepName
from app.domains.rendering.models import WorkflowDefinition
from app.domains.rendering.workflow_node_registry import (
get_node_definition,
list_node_definitions,
@@ -8,30 +9,99 @@ from app.domains.rendering.workflow_node_registry import (
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}
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():
definition = get_node_definition(StepName.BLENDER_TURNTABLE)
assert definition is not None
assert definition.family == "order_line"
assert definition.module_key == "render.production.turntable"
assert definition.node_type == "renderFramesNode"
assert definition.defaults["fps"] == 24
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} >= {
"render_engine",
"cycles_device",
"samples",
"transparent_bg",
"bg_color",
"width",
"height",
"fps",
"duration_s",
"turntable_degrees",
"turntable_axis",
"camera_orbit",
"rotation_x",
"rotation_y",
"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
async def test_node_definitions_endpoint_returns_registry(client, 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(
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["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
@@ -82,3 +201,52 @@ async def test_workflow_crud_roundtrip_preserves_execution_mode(client, auth_hea
assert get_response.status_code == 200
fetched = get_response.json()
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 { createPresetWorkflowConfig } from '../../api/workflows'
import { createPresetWorkflowConfig, createStarterWorkflowConfig, normalizeWorkflowConfig } from '../../api/workflows'
describe('workflow preset config builders', () => {
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?.execution_mode).toBe('graph')
expect(config.ui?.family).toBe('order_line')
expect(config.nodes.map(node => node.step)).toEqual([
'order_line_setup',
'auto_populate_materials',
'resolve_template',
'auto_populate_materials',
'glb_bbox',
'material_map_resolve',
'blender_still',
'output_save',
'notify',
])
expect(config.nodes.find(node => node.step === 'blender_still')?.params).toMatchObject({
use_custom_render_settings: true,
render_engine: 'cycles',
samples: 128,
width: 1600,
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 WorkflowExecutionMode = 'legacy' | 'graph' | 'shadow'
export type WorkflowStarterFamily = 'cad_file' | 'order_line'
export interface WorkflowDefinition {
id: string
name: string
output_type_id: string | null
config: WorkflowConfig
family: WorkflowNodeFamily | 'mixed' | null
is_active: boolean
created_at: string
}
@@ -54,6 +56,7 @@ export interface WorkflowEdge {
export interface WorkflowUi {
preset?: WorkflowPresetType
execution_mode?: WorkflowExecutionMode
family?: WorkflowNodeFamily | 'mixed'
blueprint?: string
}
@@ -234,9 +237,13 @@ export interface WorkflowNodeFieldDefinition {
options: WorkflowNodeFieldOption[]
}
export type WorkflowNodeFamily = 'cad_file' | 'order_line'
export interface WorkflowNodeDefinition {
step: string
label: string
family: WorkflowNodeFamily
module_key: string
category: StepCategory
description: string
node_type: string
@@ -245,6 +252,11 @@ export interface WorkflowNodeDefinition {
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 {
@@ -268,6 +280,48 @@ export const getNodeDefinitions = (): Promise<WorkflowNodeDefinitionsResponse> =
export const getPipelineSteps = (): Promise<PipelineStepsResponse> =>
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 {
const renderParams = { ...params }
const resolution = Array.isArray(renderParams.resolution) ? renderParams.resolution : undefined
@@ -280,7 +334,7 @@ function migratePresetConfig(type: WorkflowPresetType, params: WorkflowParams =
if (type === 'still') {
return {
version: 1,
ui: { preset: type, execution_mode: 'legacy' },
ui: { preset: type, execution_mode: 'legacy', family: 'order_line' },
nodes: [
{ 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 } } },
@@ -296,43 +350,19 @@ function migratePresetConfig(type: WorkflowPresetType, params: WorkflowParams =
}
if (type === 'still_graph') {
const { nodes, edges } = buildStillGraphNodes(renderParams)
return {
version: 1,
ui: { preset: type, execution_mode: 'graph' },
nodes: [
{ id: 'setup', step: 'order_line_setup', params: {}, ui: { label: 'Order Line Setup', position: { x: 0, y: 100 } } },
{
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' },
],
ui: { preset: type, execution_mode: 'graph', family: 'order_line' },
nodes,
edges,
}
}
if (type === 'turntable') {
return {
version: 1,
ui: { preset: type, execution_mode: 'legacy' },
ui: { preset: type, execution_mode: 'legacy', family: 'order_line' },
nodes: [
{ 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 } } },
@@ -353,7 +383,7 @@ function migratePresetConfig(type: WorkflowPresetType, params: WorkflowParams =
delete sharedParams.angles
return {
version: 1,
ui: { preset: type, execution_mode: 'legacy' },
ui: { preset: type, execution_mode: 'legacy', family: 'order_line' },
nodes: [
{ 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 } } },
@@ -376,7 +406,7 @@ function migratePresetConfig(type: WorkflowPresetType, params: WorkflowParams =
if (type === 'still_with_exports') {
return {
version: 1,
ui: { preset: type, execution_mode: 'legacy' },
ui: { preset: type, execution_mode: 'legacy', family: 'order_line' },
nodes: [
{ 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 } } },
@@ -395,7 +425,7 @@ function migratePresetConfig(type: WorkflowPresetType, params: WorkflowParams =
return {
version: 1,
ui: { preset: 'custom', execution_mode: 'legacy' },
ui: { preset: 'custom', execution_mode: 'legacy', family: 'order_line' },
nodes: [
{
id: 'setup',
@@ -409,25 +439,30 @@ function migratePresetConfig(type: WorkflowPresetType, params: WorkflowParams =
}
function normalizeWorkflowDefinition(raw: WorkflowDefinition): WorkflowDefinition {
const config = normalizeWorkflowConfig(raw.config as unknown as Record<string, unknown>)
return {
...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 {
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[]) : []
return {
version: Number(raw.version ?? 1),
nodes: (raw.nodes as WorkflowNode[]).map(node => ({
...node,
params: { ...(node.params ?? {}) },
})),
edges: Array.isArray(raw.edges) ? (raw.edges as WorkflowEdge[]) : [],
nodes,
edges,
ui: {
...rawUi,
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)
}
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 {
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]
}