from __future__ import annotations from copy import deepcopy from typing import Any from app.core.process_steps import StepName from app.domains.rendering.workflow_node_registry import get_node_type_for_step WorkflowPresetType = str _PRESET_TYPES = { "still", "still_graph", "turntable", "multi_angle", "still_with_exports", "custom", } _EXECUTION_MODES = {"legacy", "graph", "shadow"} _WORKFLOW_BLUEPRINTS = {"cad_intake", "order_rendering"} _WORKFLOW_STARTERS = {"cad_file", "order_line"} _NODE_TYPE_TO_STEP: dict[str, str] = { "inputNode": StepName.RESOLVE_STEP_PATH.value, "convertNode": StepName.STL_CACHE_GENERATE.value, "processNode": StepName.ORDER_LINE_SETUP.value, "renderNode": StepName.BLENDER_STILL.value, "renderFramesNode": StepName.BLENDER_TURNTABLE.value, "ffmpegNode": StepName.OUTPUT_SAVE.value, "outputNode": StepName.OUTPUT_SAVE.value, } def _make_node( node_id: str, step: StepName, x: int, y: int, *, params: dict[str, Any] | None = None, node_type: str | None = None, label: str | None = None, ) -> dict[str, Any]: return { "id": node_id, "step": step.value, "params": deepcopy(params or {}), "ui": { "type": node_type or get_node_type_for_step(step.value), "position": {"x": x, "y": y}, "label": label, }, } def _resolution_to_dimensions(params: dict[str, Any]) -> dict[str, Any]: merged = deepcopy(params) resolution = merged.pop("resolution", None) if isinstance(resolution, (list, tuple)) and len(resolution) == 2: merged.setdefault("width", int(resolution[0])) merged.setdefault("height", int(resolution[1])) return merged def build_preset_workflow_config( preset_type: WorkflowPresetType, params: dict[str, Any] | None = None, ) -> dict[str, Any]: if preset_type not in _PRESET_TYPES: raise ValueError(f"Unknown workflow preset type: {preset_type!r}") params = deepcopy(params or {}) render_params = _resolution_to_dimensions(params) if preset_type == "still": nodes = [ _make_node("setup", StepName.ORDER_LINE_SETUP, 0, 100, label="Order Line Setup"), _make_node("template", StepName.RESOLVE_TEMPLATE, 220, 100, label="Resolve Template"), _make_node( "render", StepName.BLENDER_STILL, 440, 100, params=render_params, node_type="renderNode", label="Still Render", ), _make_node("output", StepName.OUTPUT_SAVE, 660, 100, label="Save Output"), ] edges = [ {"from": "setup", "to": "template"}, {"from": "template", "to": "render"}, {"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"}, ] elif preset_type == "turntable": nodes = [ _make_node("setup", StepName.ORDER_LINE_SETUP, 0, 100, label="Order Line Setup"), _make_node("template", StepName.RESOLVE_TEMPLATE, 220, 100, label="Resolve Template"), _make_node( "turntable", StepName.BLENDER_TURNTABLE, 440, 100, params=render_params, node_type="renderFramesNode", label="Turntable Render", ), _make_node("output", StepName.OUTPUT_SAVE, 660, 100, label="Save Output"), ] edges = [ {"from": "setup", "to": "template"}, {"from": "template", "to": "turntable"}, {"from": "turntable", "to": "output"}, ] elif preset_type == "multi_angle": angles = params.get("angles") or [0, 45, 90] shared = deepcopy(render_params) shared.pop("angles", None) nodes = [ _make_node("setup", StepName.ORDER_LINE_SETUP, 0, 195, label="Order Line Setup"), _make_node("template", StepName.RESOLVE_TEMPLATE, 220, 195, label="Resolve Template"), ] edges = [ {"from": "setup", "to": "template"}, ] for index, angle in enumerate(angles): node_id = f"render_{index}" node_params = {**shared, "rotation_z": float(angle)} nodes.append( _make_node( node_id, StepName.BLENDER_STILL, 440, index * 130, params=node_params, node_type="renderNode", label=f"Render {angle}°", ) ) edges.append({"from": "template", "to": node_id}) nodes.append(_make_node("output", StepName.OUTPUT_SAVE, 700, 195, label="Save Output")) edges.extend({"from": f"render_{index}", "to": "output"} for index, _ in enumerate(angles)) elif preset_type == "still_with_exports": nodes = [ _make_node("setup", StepName.ORDER_LINE_SETUP, 0, 100, label="Order Line Setup"), _make_node("template", StepName.RESOLVE_TEMPLATE, 220, 100, label="Resolve Template"), _make_node( "render", StepName.BLENDER_STILL, 440, 100, params=render_params, node_type="renderNode", label="Still Render", ), _make_node("output", StepName.OUTPUT_SAVE, 660, 70, label="Save Output"), _make_node("blend", StepName.EXPORT_BLEND, 660, 160, label="Export Blend"), ] edges = [ {"from": "setup", "to": "template"}, {"from": "template", "to": "render"}, {"from": "render", "to": "output"}, {"from": "render", "to": "blend"}, ] else: nodes = [ _make_node("setup", StepName.ORDER_LINE_SETUP, 120, 140, label="Order Line Setup"), ] edges = [] return { "version": 1, "nodes": nodes, "edges": edges, "ui": { "preset": preset_type, "execution_mode": "graph" if preset_type == "still_graph" else "legacy", }, } def build_workflow_blueprint_config(blueprint: str) -> dict[str, Any]: if blueprint not in _WORKFLOW_BLUEPRINTS: raise ValueError(f"Unknown workflow blueprint: {blueprint!r}") if blueprint == "cad_intake": nodes = [ _make_node("resolve_step", StepName.RESOLVE_STEP_PATH, 0, 180, label="Resolve STEP Path"), _make_node("extract_objects", StepName.OCC_OBJECT_EXTRACT, 220, 180, label="Extract STEP Objects"), _make_node("export_glb", StepName.OCC_GLB_EXPORT, 440, 180, label="Export GLB"), _make_node("stl_cache", StepName.STL_CACHE_GENERATE, 660, 300, label="Generate STL Cache"), _make_node( "blender_thumb", StepName.BLENDER_RENDER, 880, 120, params={"render_engine": "cycles", "samples": 64, "width": 512, "height": 512}, node_type="renderNode", label="Render Thumbnail (Blender)", ), _make_node( "threejs_thumb", StepName.THREEJS_RENDER, 880, 320, params={"width": 512, "height": 512, "transparent_bg": True}, node_type="renderNode", label="Render Thumbnail (Three.js)", ), _make_node("save_blender_thumb", StepName.THUMBNAIL_SAVE, 1100, 120, label="Save Blender Thumbnail"), _make_node("save_threejs_thumb", StepName.THUMBNAIL_SAVE, 1100, 320, label="Save Three.js Thumbnail"), ] edges = [ {"from": "resolve_step", "to": "extract_objects"}, {"from": "extract_objects", "to": "export_glb"}, {"from": "export_glb", "to": "stl_cache"}, {"from": "export_glb", "to": "blender_thumb"}, {"from": "export_glb", "to": "threejs_thumb"}, {"from": "blender_thumb", "to": "save_blender_thumb"}, {"from": "threejs_thumb", "to": "save_threejs_thumb"}, ] else: nodes = [ _make_node("setup", StepName.ORDER_LINE_SETUP, 0, 220, label="Order Line Setup"), _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( "still_render", StepName.BLENDER_STILL, 1120, 80, params={"rotation_z": 0}, node_type="renderNode", label="Render Still", ), _make_node( "turntable_render", StepName.BLENDER_TURNTABLE, 1120, 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"), ] edges = [ {"from": "setup", "to": "bbox"}, {"from": "bbox", "to": "resolve_materials"}, {"from": "resolve_materials", "to": "populate_materials"}, {"from": "populate_materials", "to": "template"}, {"from": "template", "to": "still_render"}, {"from": "template", "to": "turntable_render"}, {"from": "template", "to": "blend_export"}, {"from": "still_render", "to": "save_still"}, {"from": "turntable_render", "to": "save_turntable"}, {"from": "save_still", "to": "notify_still"}, {"from": "save_turntable", "to": "notify_turntable"}, {"from": "blend_export", "to": "notify_export"}, ] return { "version": 1, "nodes": nodes, "edges": edges, "ui": { "preset": "custom", "execution_mode": "legacy", "blueprint": blueprint, }, } def build_starter_workflow_config(family: str = "order_line") -> dict[str, Any]: if family not in _WORKFLOW_STARTERS: raise ValueError(f"Unknown workflow starter family: {family!r}") if family == "cad_file": nodes = [ _make_node("resolve_step", StepName.RESOLVE_STEP_PATH, 120, 140, label="Resolve STEP Path"), ] blueprint = "starter_cad_intake" else: nodes = [ _make_node("setup", StepName.ORDER_LINE_SETUP, 120, 140, label="Order Line Setup"), ] blueprint = "starter_order_rendering" return { "version": 1, "nodes": nodes, "edges": [], "ui": { "preset": "custom", "execution_mode": "legacy", "blueprint": blueprint, }, } def _build_legacy_custom_render_fallback_config(params: dict[str, Any] | None = None) -> dict[str, Any]: render_params = _resolution_to_dimensions(params or {}) render_params.setdefault("use_custom_render_settings", True) return { "version": 1, "nodes": [ _make_node("setup", StepName.ORDER_LINE_SETUP, 0, 140, label="Order Line Setup"), _make_node( "render", StepName.BLENDER_STILL, 240, 140, params=render_params, node_type="renderNode", label="Still Render", ), ], "edges": [ {"from": "setup", "to": "render"}, ], "ui": { "preset": "custom", "execution_mode": "legacy", "blueprint": "starter_order_rendering", }, } def _canonicalize_legacy_custom_config(raw: dict[str, Any]) -> dict[str, Any]: legacy_nodes = raw.get("nodes") or [] legacy_edges = raw.get("edges") or [] raw_ui = raw.get("ui") if not legacy_nodes: canonical = _build_legacy_custom_render_fallback_config(raw.get("params") or {}) if isinstance(raw_ui, dict): merged_ui = dict(canonical.get("ui") or {}) merged_ui.update(raw_ui) if merged_ui.get("execution_mode") not in _EXECUTION_MODES: merged_ui["execution_mode"] = "legacy" canonical["ui"] = merged_ui return canonical nodes: list[dict[str, Any]] = [] for legacy_node in legacy_nodes: data = legacy_node.get("data") or {} node_type = legacy_node.get("type") step_name = data.get("pipeline_step") or _NODE_TYPE_TO_STEP.get(node_type) or StepName.BLENDER_STILL.value nodes.append( { "id": legacy_node["id"], "step": step_name, "params": deepcopy(data.get("params") or {}), "ui": { "type": node_type, "position": deepcopy(legacy_node.get("position") or {"x": 0, "y": 0}), "label": data.get("label"), }, } ) edges: list[dict[str, Any]] = [] for index, legacy_edge in enumerate(legacy_edges): source = legacy_edge.get("source") or legacy_edge.get("from") target = legacy_edge.get("target") or legacy_edge.get("to") if not source or not target: continue edges.append( { "id": legacy_edge.get("id") or f"edge_{index}", "from": source, "to": target, } ) canonical = { "version": 1, "nodes": nodes, "edges": edges, "ui": { "preset": "custom", "execution_mode": "legacy", }, } if isinstance(raw_ui, dict): merged_ui = dict(canonical.get("ui") or {}) merged_ui.update(raw_ui) if merged_ui.get("execution_mode") not in _EXECUTION_MODES: merged_ui["execution_mode"] = "legacy" canonical["ui"] = merged_ui return canonical def canonicalize_workflow_config(raw: dict[str, Any]) -> dict[str, Any]: if not isinstance(raw, dict): raise ValueError("Workflow config must be a JSON object") if "version" in raw and "nodes" in raw: normalized = deepcopy(raw) normalized.setdefault("edges", []) ui = normalized.get("ui") if not isinstance(ui, dict): ui = {} normalized["ui"] = dict(ui) normalized["ui"].setdefault("execution_mode", "legacy") return normalized workflow_type = raw.get("type") if workflow_type in _PRESET_TYPES: if workflow_type == "custom": return _canonicalize_legacy_custom_config(raw) canonical = build_preset_workflow_config(workflow_type, raw.get("params") or {}) raw_ui = raw.get("ui") if isinstance(raw_ui, dict): merged_ui = dict(canonical.get("ui") or {}) merged_ui.update(raw_ui) if merged_ui.get("execution_mode") not in _EXECUTION_MODES: merged_ui["execution_mode"] = "legacy" canonical["ui"] = merged_ui return canonical raise ValueError("Unsupported workflow config format") def workflow_config_requires_canonicalization(raw: dict[str, Any]) -> bool: if not isinstance(raw, dict): return True if "version" not in raw or "nodes" not in raw: return True return raw != canonicalize_workflow_config(raw) def get_workflow_preset_type(config: dict[str, Any]) -> str | None: canonical = canonicalize_workflow_config(config) ui = canonical.get("ui") or {} preset = ui.get("preset") if preset in _PRESET_TYPES: return preset return None def get_workflow_execution_mode(config: dict[str, Any], *, default: str = "legacy") -> str: canonical = canonicalize_workflow_config(config) ui = canonical.get("ui") or {} mode = ui.get("execution_mode") if mode in _EXECUTION_MODES: return mode return default def extract_runtime_workflow(config: dict[str, Any]) -> tuple[str | None, dict[str, Any]]: canonical = canonicalize_workflow_config(config) preset = get_workflow_preset_type(canonical) if preset is None or preset == "custom": return preset, {} nodes = canonical.get("nodes") or [] if preset in {"still", "still_graph", "still_with_exports"}: for node in nodes: if node.get("step") == StepName.BLENDER_STILL.value: return preset, _resolution_to_dimensions(node.get("params") or {}) return preset, {} if preset == "turntable": for node in nodes: if node.get("step") == StepName.BLENDER_TURNTABLE.value: return preset, _resolution_to_dimensions(node.get("params") or {}) return preset, {} if preset == "multi_angle": render_nodes = [node for node in nodes if node.get("step") == StepName.BLENDER_STILL.value] if not render_nodes: return preset, {} first_params = _resolution_to_dimensions(render_nodes[0].get("params") or {}) angles = [ float((node.get("params") or {}).get("rotation_z", 0)) for node in render_nodes ] first_params["angles"] = angles first_params.pop("rotation_z", None) return preset, first_params return preset, {}