feat: expose graph still workflow in editor

This commit is contained in:
2026-04-08 11:16:47 +02:00
parent ffcaef4659
commit 7e100ed334
5 changed files with 1840 additions and 195 deletions
@@ -11,6 +11,7 @@ WorkflowPresetType = str
_PRESET_TYPES = {
"still",
"still_graph",
"turntable",
"multi_angle",
"still_with_exports",
@@ -18,6 +19,8 @@ _PRESET_TYPES = {
}
_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,
@@ -29,7 +32,6 @@ _NODE_TYPE_TO_STEP: dict[str, str] = {
"outputNode": StepName.OUTPUT_SAVE.value,
}
def _make_node(
node_id: str,
step: StepName,
@@ -91,6 +93,32 @@ def build_preset_workflow_config(
{"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"),
@@ -173,7 +201,162 @@ def build_preset_workflow_config(
"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",
},
}
@@ -181,6 +364,18 @@ def build_preset_workflow_config(
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 {}
@@ -213,7 +408,7 @@ def _canonicalize_legacy_custom_config(raw: dict[str, Any]) -> dict[str, Any]:
}
)
return {
canonical = {
"version": 1,
"nodes": nodes,
"edges": edges,
@@ -222,6 +417,13 @@ def _canonicalize_legacy_custom_config(raw: dict[str, Any]) -> dict[str, Any]:
"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]:
@@ -242,11 +444,29 @@ def canonicalize_workflow_config(raw: dict[str, Any]) -> dict[str, Any]:
if workflow_type in _PRESET_TYPES:
if workflow_type == "custom":
return _canonicalize_legacy_custom_config(raw)
return build_preset_workflow_config(workflow_type, raw.get("params") or {})
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 {}
@@ -273,7 +493,7 @@ def extract_runtime_workflow(config: dict[str, Any]) -> tuple[str | None, dict[s
nodes = canonical.get("nodes") or []
if preset in {"still", "still_with_exports"}:
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 {})
@@ -1,7 +1,11 @@
from app.domains.rendering.workflow_config_utils import (
build_preset_workflow_config,
build_workflow_blueprint_config,
build_starter_workflow_config,
canonicalize_workflow_config,
extract_runtime_workflow,
get_workflow_execution_mode,
workflow_config_requires_canonicalization,
)
@@ -25,20 +29,56 @@ def test_build_preset_workflow_config_creates_canonical_dag():
assert render_node["params"]["height"] == 1080
def test_build_preset_workflow_config_creates_graph_still_variant():
config = build_preset_workflow_config(
"still_graph",
{"render_engine": "cycles", "samples": 128, "resolution": [1600, 900]},
)
assert config["version"] == 1
assert config["ui"]["preset"] == "still_graph"
assert config["ui"]["execution_mode"] == "graph"
assert [node["step"] for node in config["nodes"]] == [
"order_line_setup",
"auto_populate_materials",
"resolve_template",
"material_map_resolve",
"blender_still",
"output_save",
"notify",
]
render_node = next(node for node in config["nodes"] if node["step"] == "blender_still")
assert render_node["params"]["width"] == 1600
assert render_node["params"]["height"] == 900
assert render_node["params"]["samples"] == 128
def test_canonicalize_workflow_config_migrates_legacy_preset():
legacy = {
"type": "turntable",
"params": {"render_engine": "cycles", "samples": 64, "fps": 24},
"ui": {"execution_mode": "shadow", "label": "Legacy Turntable"},
}
canonical = canonicalize_workflow_config(legacy)
assert canonical["version"] == 1
assert canonical["ui"]["preset"] == "turntable"
assert canonical["ui"]["execution_mode"] == "legacy"
assert canonical["ui"]["execution_mode"] == "shadow"
assert canonical["ui"]["label"] == "Legacy Turntable"
assert any(node["step"] == "blender_turntable" for node in canonical["nodes"])
def test_get_workflow_execution_mode_uses_legacy_preset_ui_override():
legacy = {
"type": "still",
"params": {"width": 1024, "height": 768},
"ui": {"execution_mode": "graph"},
}
assert get_workflow_execution_mode(legacy) == "graph"
def test_extract_runtime_workflow_uses_canonical_render_node_params():
config = build_preset_workflow_config(
"multi_angle",
@@ -82,6 +122,34 @@ def test_canonicalize_legacy_custom_config_preserves_edges():
assert canonical["edges"] == [{"id": "e1", "from": "input", "to": "render"}]
def test_canonicalize_legacy_custom_config_without_nodes_builds_render_fallback_graph():
legacy = {
"type": "custom",
"params": {
"samples": 256,
"resolution": [2048, 2048],
"render_engine": "cycles",
},
}
canonical = canonicalize_workflow_config(legacy)
assert canonical["version"] == 1
assert canonical["ui"]["preset"] == "custom"
assert canonical["ui"]["execution_mode"] == "legacy"
assert [node["step"] for node in canonical["nodes"]] == [
"order_line_setup",
"blender_still",
]
assert canonical["edges"] == [{"from": "setup", "to": "render"}]
render_node = next(node for node in canonical["nodes"] if node["step"] == "blender_still")
assert render_node["params"]["render_engine"] == "cycles"
assert render_node["params"]["samples"] == 256
assert render_node["params"]["width"] == 2048
assert render_node["params"]["height"] == 2048
assert render_node["params"]["use_custom_render_settings"] is True
def test_extract_runtime_workflow_converts_resolution_to_dimensions():
config = build_preset_workflow_config(
"turntable",
@@ -96,6 +164,41 @@ def test_extract_runtime_workflow_converts_resolution_to_dimensions():
assert "resolution" not in params
def test_build_preset_workflow_config_keeps_render_overrides_opt_in_disabled_by_default():
config = build_preset_workflow_config(
"still_with_exports",
{"width": 640, "height": 640, "samples": 32},
)
render_node = next(node for node in config["nodes"] if node["step"] == "blender_still")
assert "use_custom_render_settings" not in render_node["params"]
assert render_node["params"]["width"] == 640
assert render_node["params"]["height"] == 640
assert render_node["params"]["samples"] == 32
def test_canonicalize_workflow_config_preserves_unset_override_intent_for_saved_preset_configs():
canonical = canonicalize_workflow_config(
{
"version": 1,
"nodes": [
{"id": "setup", "step": "order_line_setup", "params": {}},
{
"id": "render",
"step": "blender_still",
"params": {"width": 1024, "height": 768},
},
],
"edges": [{"from": "setup", "to": "render"}],
"ui": {"preset": "still_with_exports", "execution_mode": "graph"},
}
)
render_node = next(node for node in canonical["nodes"] if node["step"] == "blender_still")
assert "use_custom_render_settings" not in render_node["params"]
def test_canonicalize_workflow_config_defaults_execution_mode_for_canonical_configs():
canonical = canonicalize_workflow_config(
{
@@ -110,3 +213,68 @@ def test_canonicalize_workflow_config_defaults_execution_mode_for_canonical_conf
assert canonical["ui"]["preset"] == "custom"
assert canonical["ui"]["execution_mode"] == "legacy"
def test_workflow_config_requires_canonicalization_for_legacy_payloads():
assert workflow_config_requires_canonicalization(
{
"type": "still",
"params": {"width": 1024, "height": 768},
}
)
def test_workflow_config_requires_canonicalization_skips_already_canonical_configs():
config = build_preset_workflow_config("still", {"width": 1024, "height": 768})
assert workflow_config_requires_canonicalization(config) is False
def test_build_workflow_blueprint_config_creates_cad_intake_family_graph():
config = build_workflow_blueprint_config("cad_intake")
assert config["version"] == 1
assert config["ui"]["preset"] == "custom"
assert config["ui"]["blueprint"] == "cad_intake"
assert [node["step"] for node in config["nodes"]] == [
"resolve_step_path",
"occ_object_extract",
"occ_glb_export",
"stl_cache_generate",
"blender_render",
"threejs_render",
"thumbnail_save",
"thumbnail_save",
]
def test_build_workflow_blueprint_config_creates_order_rendering_family_graph():
config = build_workflow_blueprint_config("order_rendering")
assert config["version"] == 1
assert config["ui"]["preset"] == "custom"
assert config["ui"]["blueprint"] == "order_rendering"
assert any(node["step"] == "blender_still" for node in config["nodes"])
assert any(node["step"] == "blender_turntable" for node in config["nodes"])
assert any(node["step"] == "export_blend" for node in config["nodes"])
assert sum(1 for node in config["nodes"] if node["step"] == "notify") == 3
def test_build_starter_workflow_config_creates_minimal_valid_custom_graph():
config = build_starter_workflow_config()
assert config["version"] == 1
assert config["ui"]["preset"] == "custom"
assert config["ui"]["blueprint"] == "starter_order_rendering"
assert config["nodes"] == [
{
"id": "setup",
"step": "order_line_setup",
"params": {},
"ui": {
"type": "processNode",
"position": {"x": 120, "y": 140},
"label": "Order Line Setup",
},
}
]