feat: BLENDER_CINEMATIC workflow graph node (M1)

Add StepName.BLENDER_CINEMATIC and full graph runtime support so cinematic
output types can be promoted from legacy_only to graph/shadow rollout mode.

- process_steps.py: add BLENDER_CINEMATIC = "blender_cinematic" enum value
- workflow_executor.py: map to render_cinematic_task in STEP_TASK_MAP
- workflow_node_registry.py: node definition with render/scene/camera fields
  (no animation params — cinematic is fixed at 250 frames @ 25fps)
- workflow_graph_runtime.py: _ORDER_LINE_RENDER_STEPS, _CINEMATIC_TASK_KEYS,
  shadow queue routing, predict_render_output_artifact (mp4),
  _build_task_kwargs, _artifact_kind_override_for_step
- tasks.py: _normalize_cinematic_params + render_cinematic_task Celery task
  (calls render_cinematic_to_file, publishes as turntable asset type since mp4)

No DB migration needed: admins can now manually set cinematic output types
to graph rollout mode via the admin panel and assign a workflow definition.

docs: learnings erfasst — BLENDER_CINEMATIC workflow graph node M1

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 15:09:47 +02:00
co-authored by Claude Sonnet 4.6
parent 10a8bbd977
commit 3401b06b19
6 changed files with 473 additions and 1 deletions
@@ -61,6 +61,7 @@ class WorkflowGraphState:
_ORDER_LINE_RENDER_STEPS = {
StepName.BLENDER_STILL,
StepName.BLENDER_TURNTABLE,
StepName.BLENDER_CINEMATIC,
StepName.EXPORT_BLEND,
StepName.OUTPUT_SAVE,
StepName.NOTIFY,
@@ -133,6 +134,33 @@ _TURNTABLE_TASK_KEYS = {
"duration_s",
}
_CINEMATIC_TASK_KEYS = {
"width",
"height",
"engine",
"render_engine",
"samples",
"smooth_angle",
"cycles_device",
"transparent_bg",
"part_colors",
"template_path",
"target_collection",
"material_library_path",
"material_map",
"part_names_ordered",
"lighting_only",
"shadow_catcher",
"rotation_x",
"rotation_y",
"rotation_z",
"usd_path",
"focal_length_mm",
"sensor_width_mm",
"material_override",
"template_inputs",
}
_THUMBNAIL_TASK_KEYS = {
"renderer",
"render_engine",
@@ -232,6 +260,7 @@ def _resolve_shadow_render_queue(
if node.step not in {
StepName.BLENDER_STILL,
StepName.BLENDER_TURNTABLE,
StepName.BLENDER_CINEMATIC,
StepName.EXPORT_BLEND,
}:
return None
@@ -810,6 +839,27 @@ def _predict_task_output_metadata(
"graph_notify_node_ids": list(task_kwargs.get("graph_notify_node_ids") or []),
}
if node.step == StepName.BLENDER_CINEMATIC:
output_name_suffix = task_kwargs.get("output_name_suffix")
cinematic_filename = f"line_{order_line_id}_cinematic.mp4"
if output_name_suffix:
cinematic_filename = f"line_{order_line_id}_cinematic_{output_name_suffix}.mp4"
predicted_output_path = str(
build_order_line_step_render_path(step_path, order_line_id, cinematic_filename)
)
return {
"artifact_role": "cinematic_output",
"predicted_output_path": predicted_output_path,
"predicted_asset_type": "turntable",
"publish_asset_enabled": bool(task_kwargs.get("publish_asset_enabled", True)),
"graph_authoritative_output_enabled": bool(
task_kwargs.get("graph_authoritative_output_enabled", False)
),
"graph_output_node_ids": list(task_kwargs.get("graph_output_node_ids") or []),
"notify_handoff_enabled": bool(task_kwargs.get("emit_legacy_notifications", False)),
"graph_notify_node_ids": list(task_kwargs.get("graph_notify_node_ids") or []),
}
return {}
@@ -963,6 +1013,16 @@ def _build_task_kwargs(
"turntable.mp4",
).parent
)
elif node.step == StepName.BLENDER_CINEMATIC:
task_kwargs = _filter_graph_render_overrides(StepName.BLENDER_CINEMATIC, task_kwargs)
task_kwargs = {
key: value
for key, value in {
**render_defaults,
**task_kwargs,
}.items()
if key in _CINEMATIC_TASK_KEYS
}
elif node.step == StepName.THUMBNAIL_SAVE:
thumbnail_request = _resolve_thumbnail_request(workflow_context, state, node.id) or {}
task_kwargs = {
@@ -980,6 +1040,7 @@ def _build_task_kwargs(
StepName.BLENDER_STILL,
StepName.EXPORT_BLEND,
StepName.BLENDER_TURNTABLE,
StepName.BLENDER_CINEMATIC,
}:
connected_output_node_ids = _connected_node_ids_by_step(
workflow_context,
@@ -1015,6 +1076,8 @@ def _build_task_kwargs(
def _artifact_kind_override_for_step(step: StepName) -> str | None:
if step == StepName.BLENDER_TURNTABLE:
return "turntable_video"
if step == StepName.BLENDER_CINEMATIC:
return "cinematic_video"
if step == StepName.BLENDER_STILL:
return "still_image"
if step == StepName.EXPORT_BLEND: