diff --git a/backend/app/domains/rendering/workflow_config_utils.py b/backend/app/domains/rendering/workflow_config_utils.py index 6c16040..87cfc93 100644 --- a/backend/app/domains/rendering/workflow_config_utils.py +++ b/backend/app/domains/rendering/workflow_config_utils.py @@ -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 diff --git a/backend/app/domains/rendering/workflow_node_registry.py b/backend/app/domains/rendering/workflow_node_registry.py index 6b95e05..304d505 100644 --- a/backend/app/domains/rendering/workflow_node_registry.py +++ b/backend/app/domains/rendering/workflow_node_registry.py @@ -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"], ), ] diff --git a/backend/app/domains/rendering/workflow_schema.py b/backend/app/domains/rendering/workflow_schema.py index 04e416f..d62ddb9 100644 --- a/backend/app/domains/rendering/workflow_schema.py +++ b/backend/app/domains/rendering/workflow_schema.py @@ -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 diff --git a/backend/tests/domains/test_workflow_node_registry.py b/backend/tests/domains/test_workflow_node_registry.py index c4c9907..95eb4a6 100644 --- a/backend/tests/domains/test_workflow_node_registry.py +++ b/backend/tests/domains/test_workflow_node_registry.py @@ -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" diff --git a/backend/tests/domains/test_workflow_schema.py b/backend/tests/domains/test_workflow_schema.py new file mode 100644 index 0000000..95596ac --- /dev/null +++ b/backend/tests/domains/test_workflow_schema.py @@ -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"}, + } + ) diff --git a/frontend/src/__tests__/api/workflows.test.ts b/frontend/src/__tests__/api/workflows.test.ts index aa89904..8c7da67 100644 --- a/frontend/src/__tests__/api/workflows.test.ts +++ b/frontend/src/__tests__/api/workflows.test.ts @@ -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') + }) }) diff --git a/frontend/src/api/workflows.ts b/frontend/src/api/workflows.ts index 8b3adf3..705b9fa 100644 --- a/frontend/src/api/workflows.ts +++ b/frontend/src/api/workflows.ts @@ -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 + output_contract: Record + artifact_roles_produced: string[] + artifact_roles_consumed: string[] + legacy_source: string | null } export interface WorkflowNodeDefinitionsResponse { @@ -268,6 +280,48 @@ export const getNodeDefinitions = (): Promise = export const getPipelineSteps = (): Promise => 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) return { ...raw, - config: normalizeWorkflowConfig(raw.config as unknown as Record), + family: raw.family ?? inferWorkflowFamily(config), + config, } } export function normalizeWorkflowConfig(raw: Record): 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] +}