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
@@ -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"],
),
]