feat: harden workflow graph contracts
This commit is contained in:
@@ -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"},
|
||||
}
|
||||
)
|
||||
@@ -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
@@ -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]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user