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
+3
View File
@@ -7,6 +7,9 @@
## Learnings ## Learnings
### 2026-07-22 | Architecture | BLENDER_CINEMATIC Workflow-Graph-Node implementiert
Der cinematic Render-Pfad hatte keinen eigenen Workflow-Graph-Node — Migration 071 hatte alle cinematic Output-Types auf `legacy_only` gezwungen als Sicherheitsnetz. M1 fügt jetzt `StepName.BLENDER_CINEMATIC` hinzu, zusammen mit: (1) Node-Definition im `workflow_node_registry.py` mit denselben Szene/Camera/Material-Feldern wie BLENDER_STILL, aber ohne Animations-Params (frame_count/fps sind im cinematic_render.py-Script hartkodiert auf 250 @ 25fps), (2) `render_cinematic_task` in `tasks.py` — folgt dem Pattern von `render_order_line_still_task`, gibt mp4 aus, nutzt `_finalize_graph_turntable_output`/`_finalize_shadow_turntable_output` da cinematic = mp4, published mit `asset_type="turntable"`, (3) STEP_TASK_MAP + `_ORDER_LINE_RENDER_STEPS` + `_build_task_kwargs` + `_predict_render_output_artifact` + `_artifact_kind_override_for_step` in `workflow_graph_runtime.py` alle aktualisiert. Kein neues DB-Migration nötig: Admins können cinematic Output-Types jetzt manuell von `legacy_only` auf `graph` umstellen und ein Workflow-Definition mit BLENDER_CINEMATIC-Node zuweisen.
### 2026-07-22 | Architecture | Cinematic Output Type existierte bereits mit falschem workflow_rollout_mode ### 2026-07-22 | Architecture | Cinematic Output Type existierte bereits mit falschem workflow_rollout_mode
Der "Cinematic Highlight" Output-Type war seit März 2026 in der DB mit `workflow_rollout_mode = shadow` und einer `workflow_definition_id` gesetzt. Da es keinen `BLENDER_CINEMATIC`-Node im Workflow-Graph gibt, hätte jeder Cinematic-Render eine Shadow-Graph-Execution ausgelöst, die still scheitert. Fix via Migration 071: alle cinematic Output-Types auf `legacy_only` + `workflow_definition_id = NULL` patchen. Zusätzlich API-Guard in POST/PATCH `output_types.py` eingebaut: cinematic + workflow_definition_id → 400 Error. Defense-in-depth im `dispatch_service.py`: frühzeitiger Legacy-Exit wenn `render_settings.cinematic = true`. Der "Cinematic Highlight" Output-Type war seit März 2026 in der DB mit `workflow_rollout_mode = shadow` und einer `workflow_definition_id` gesetzt. Da es keinen `BLENDER_CINEMATIC`-Node im Workflow-Graph gibt, hätte jeder Cinematic-Render eine Shadow-Graph-Execution ausgelöst, die still scheitert. Fix via Migration 071: alle cinematic Output-Types auf `legacy_only` + `workflow_definition_id = NULL` patchen. Zusätzlich API-Guard in POST/PATCH `output_types.py` eingebaut: cinematic + workflow_definition_id → 400 Error. Defense-in-depth im `dispatch_service.py`: frühzeitiger Legacy-Exit wenn `render_settings.cinematic = true`.
+1
View File
@@ -25,6 +25,7 @@ class StepName(StrEnum):
RESOLVE_TEMPLATE = "resolve_template" RESOLVE_TEMPLATE = "resolve_template"
BLENDER_STILL = "blender_still" BLENDER_STILL = "blender_still"
BLENDER_TURNTABLE = "blender_turntable" BLENDER_TURNTABLE = "blender_turntable"
BLENDER_CINEMATIC = "blender_cinematic"
OUTPUT_SAVE = "output_save" OUTPUT_SAVE = "output_save"
# ── Asset export ────────────────────────────────────────────────── # ── Asset export ──────────────────────────────────────────────────
+245
View File
@@ -1631,6 +1631,251 @@ def render_order_line_still_task(self, order_line_id: str, **params) -> dict:
raise self.retry(exc=exc, countdown=30) raise self.retry(exc=exc, countdown=30)
def _normalize_cinematic_params(params: dict) -> dict:
"""Map graph/editor params onto render_cinematic_to_file kwargs."""
normalized = dict(params)
normalized.pop("use_custom_render_settings", None)
legacy_engine = normalized.pop("render_engine", None)
if legacy_engine is not None and normalized.get("engine") is None:
normalized["engine"] = legacy_engine
usd_path = normalized.get("usd_path")
if isinstance(usd_path, str) and usd_path.strip():
normalized["usd_path"] = Path(usd_path)
elif usd_path == "":
normalized.pop("usd_path", None)
for key in _RENDER_STILL_CONTROL_PARAM_KEYS:
normalized.pop(key, None)
return normalized
@celery_app.task(
bind=True,
name="app.domains.rendering.tasks.render_cinematic_task",
queue="asset_pipeline",
max_retries=2,
)
def render_cinematic_task(self, order_line_id: str, **params) -> dict:
"""Render a cinematic highlight animation for an order line (250 frames @ 25 fps).
Accepts order_line_id as context, resolves the STEP path from DB, runs
render_cinematic_to_file, and publishes the result as a media asset.
"""
from app.domains.rendering.job_document import RenderJobDocument, JobState
from app.core.process_steps import StepName
workflow_run_id = params.pop("workflow_run_id", None)
workflow_node_id = params.pop("workflow_node_id", None)
publish_asset_enabled = bool(params.pop("publish_asset_enabled", True))
observer_output_enabled = bool(params.pop("observer_output_enabled", False))
graph_authoritative_output_enabled = bool(params.pop("graph_authoritative_output_enabled", False))
graph_output_node_ids = list(params.pop("graph_output_node_ids", []) or [])
graph_notify_node_ids = list(params.pop("graph_notify_node_ids", []) or [])
emit_events = bool(params.pop("emit_events", True))
job_document_enabled = bool(params.pop("job_document_enabled", True))
emit_legacy_notifications = bool(params.pop("emit_legacy_notifications", False))
output_name_suffix = params.pop("output_name_suffix", None)
log_task_event(self.request.id, f"Starting render_cinematic_task: order_line={order_line_id}", "info")
_mark_workflow_node_running(
order_line_id,
workflow_run_id=workflow_run_id,
workflow_node_id=workflow_node_id,
task_id=self.request.id,
)
job_doc = RenderJobDocument.new(order_line_id=order_line_id, celery_task_id=self.request.id)
job_doc.set_state(JobState.RUNNING)
def _save_job_doc():
if not job_document_enabled:
return
try:
from sqlalchemy import update as _upd
from app.core.db_utils import get_sync_session
from app.domains.orders.models import OrderLine
with get_sync_session() as db:
db.execute(
_upd(OrderLine)
.where(OrderLine.id == order_line_id)
.values(render_job_doc=job_doc.to_dict())
)
except Exception as _exc:
logger.debug("_save_job_doc failed: %s", _exc)
_save_job_doc()
job_doc.begin_step(StepName.RESOLVE_STEP_PATH)
step_path_str, _cad_file_id = _resolve_step_path_for_order_line(order_line_id)
if not step_path_str:
job_doc.fail_step(StepName.RESOLVE_STEP_PATH, "product missing or has no linked CAD file")
job_doc.set_state(JobState.FAILED, error="Cannot resolve STEP path")
_save_job_doc()
log_task_event(self.request.id, f"Failed: cannot resolve STEP path for order_line {order_line_id}", "error")
raise RuntimeError(
f"Cannot resolve STEP path for order_line {order_line_id}: "
"product missing or has no linked CAD file"
)
job_doc.finish_step(StepName.RESOLVE_STEP_PATH, output={"step_path": step_path_str})
step = Path(step_path_str)
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"
output_path = build_order_line_step_render_path(
step,
order_line_id,
cinematic_filename,
ensure_exists=True,
)
render_params = _normalize_cinematic_params(params)
try:
job_doc.begin_step(StepName.BLENDER_CINEMATIC)
from app.services.render_blender import render_cinematic_to_file
result = render_cinematic_to_file(
step_path=step,
output_path=output_path,
**render_params,
)
job_doc.finish_step(
StepName.BLENDER_CINEMATIC,
output={"output_path": str(output_path), "duration_s": result.get("total_duration_s")},
)
job_doc.set_state(JobState.COMPLETED, result={
"output_path": str(output_path),
"duration_s": result.get("total_duration_s"),
"engine_used": result.get("engine_used"),
})
_save_job_doc()
if graph_authoritative_output_enabled:
_finalize_graph_turntable_output(
order_line_id,
success=True,
output_path=str(output_path),
render_log=result,
workflow_run_id=workflow_run_id,
output_node_ids=graph_output_node_ids,
render_node_id=workflow_node_id,
)
elif observer_output_enabled:
_finalize_shadow_turntable_output(
order_line_id,
success=True,
output_path=str(output_path),
render_log=result,
workflow_run_id=workflow_run_id,
output_node_ids=graph_output_node_ids,
render_node_id=workflow_node_id,
)
elif publish_asset_enabled:
publish_asset.delay(
order_line_id,
"turntable",
str(output_path),
render_config=result,
workflow_run_id=workflow_run_id,
)
log_task_event(self.request.id, f"Completed successfully in {result.get('total_duration_s', 0):.1f}s", "done")
logger.info(
"render_cinematic_task completed for line %s in %.1fs",
order_line_id, result.get("total_duration_s", 0),
)
try:
from app.core.websocket import publish_event_sync
if emit_events:
publish_event_sync(None, {
"type": "render.order_line.completed",
"order_line_id": order_line_id,
})
except Exception:
pass
if emit_legacy_notifications:
_emit_graph_render_notifications(
order_line_id,
success=True,
render_log=result,
)
_finalize_graph_notify_nodes(
workflow_run_id=workflow_run_id,
notify_node_ids=graph_notify_node_ids,
success=True,
render_node_id=workflow_node_id,
)
_update_workflow_run_status(
order_line_id,
"completed",
workflow_run_id=workflow_run_id,
workflow_node_id=workflow_node_id,
)
return result
except Exception as exc:
job_doc.fail_step(StepName.BLENDER_CINEMATIC, str(exc))
job_doc.set_state(JobState.FAILED, error=str(exc))
_save_job_doc()
log_task_event(self.request.id, f"Failed: {exc}", "error")
logger.error("render_cinematic_task failed for %s: %s", order_line_id, exc)
try:
from app.core.websocket import publish_event_sync
if emit_events:
publish_event_sync(None, {
"type": "render.order_line.failed",
"order_line_id": order_line_id,
"error": str(exc),
})
except Exception:
pass
if graph_authoritative_output_enabled:
_finalize_graph_turntable_output(
order_line_id,
success=False,
output_path=str(output_path),
render_log={"error": str(exc)},
workflow_run_id=workflow_run_id,
output_node_ids=graph_output_node_ids,
render_node_id=workflow_node_id,
error=str(exc),
)
elif observer_output_enabled:
_finalize_shadow_turntable_output(
order_line_id,
success=False,
output_path=str(output_path),
render_log={"error": str(exc)},
workflow_run_id=workflow_run_id,
output_node_ids=graph_output_node_ids,
render_node_id=workflow_node_id,
error=str(exc),
)
if emit_legacy_notifications:
_emit_graph_render_notifications(
order_line_id,
success=False,
render_log={"error": str(exc)},
)
_finalize_graph_notify_nodes(
workflow_run_id=workflow_run_id,
notify_node_ids=graph_notify_node_ids,
success=False,
render_node_id=workflow_node_id,
error=str(exc),
)
_update_workflow_run_status(
order_line_id,
"failed",
str(exc),
workflow_run_id=workflow_run_id,
workflow_node_id=workflow_node_id,
)
raise self.retry(exc=exc, countdown=60)
@celery_app.task( @celery_app.task(
bind=True, bind=True,
name="app.domains.rendering.tasks.export_blend_for_order_line_task", name="app.domains.rendering.tasks.export_blend_for_order_line_task",
@@ -109,9 +109,10 @@ STEP_TASK_MAP: dict[StepName, str] = {
# ── Thumbnail generation ───────────────────────────────────────────── # ── Thumbnail generation ─────────────────────────────────────────────
StepName.BLENDER_RENDER: "app.tasks.step_tasks.render_step_thumbnail", StepName.BLENDER_RENDER: "app.tasks.step_tasks.render_step_thumbnail",
StepName.THUMBNAIL_SAVE: "app.tasks.step_tasks.render_graph_thumbnail", StepName.THUMBNAIL_SAVE: "app.tasks.step_tasks.render_graph_thumbnail",
# ── Order line stills & turntables ────────────────────────────────── # ── Order line stills, turntables & cinematics ──────────────────────
StepName.BLENDER_STILL: "app.domains.rendering.tasks.render_order_line_still_task", StepName.BLENDER_STILL: "app.domains.rendering.tasks.render_order_line_still_task",
StepName.BLENDER_TURNTABLE: "app.domains.rendering.tasks.render_turntable_task", StepName.BLENDER_TURNTABLE: "app.domains.rendering.tasks.render_turntable_task",
StepName.BLENDER_CINEMATIC: "app.domains.rendering.tasks.render_cinematic_task",
# ── Asset export ───────────────────────────────────────────────────── # ── Asset export ─────────────────────────────────────────────────────
StepName.EXPORT_BLEND: "app.domains.rendering.tasks.export_blend_for_order_line_task", StepName.EXPORT_BLEND: "app.domains.rendering.tasks.export_blend_for_order_line_task",
# ── Steps without a dedicated standalone task (no mapping) ─────────── # ── Steps without a dedicated standalone task (no mapping) ───────────
@@ -61,6 +61,7 @@ class WorkflowGraphState:
_ORDER_LINE_RENDER_STEPS = { _ORDER_LINE_RENDER_STEPS = {
StepName.BLENDER_STILL, StepName.BLENDER_STILL,
StepName.BLENDER_TURNTABLE, StepName.BLENDER_TURNTABLE,
StepName.BLENDER_CINEMATIC,
StepName.EXPORT_BLEND, StepName.EXPORT_BLEND,
StepName.OUTPUT_SAVE, StepName.OUTPUT_SAVE,
StepName.NOTIFY, StepName.NOTIFY,
@@ -133,6 +134,33 @@ _TURNTABLE_TASK_KEYS = {
"duration_s", "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 = { _THUMBNAIL_TASK_KEYS = {
"renderer", "renderer",
"render_engine", "render_engine",
@@ -232,6 +260,7 @@ def _resolve_shadow_render_queue(
if node.step not in { if node.step not in {
StepName.BLENDER_STILL, StepName.BLENDER_STILL,
StepName.BLENDER_TURNTABLE, StepName.BLENDER_TURNTABLE,
StepName.BLENDER_CINEMATIC,
StepName.EXPORT_BLEND, StepName.EXPORT_BLEND,
}: }:
return None return None
@@ -810,6 +839,27 @@ def _predict_task_output_metadata(
"graph_notify_node_ids": list(task_kwargs.get("graph_notify_node_ids") or []), "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 {} return {}
@@ -963,6 +1013,16 @@ def _build_task_kwargs(
"turntable.mp4", "turntable.mp4",
).parent ).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: elif node.step == StepName.THUMBNAIL_SAVE:
thumbnail_request = _resolve_thumbnail_request(workflow_context, state, node.id) or {} thumbnail_request = _resolve_thumbnail_request(workflow_context, state, node.id) or {}
task_kwargs = { task_kwargs = {
@@ -980,6 +1040,7 @@ def _build_task_kwargs(
StepName.BLENDER_STILL, StepName.BLENDER_STILL,
StepName.EXPORT_BLEND, StepName.EXPORT_BLEND,
StepName.BLENDER_TURNTABLE, StepName.BLENDER_TURNTABLE,
StepName.BLENDER_CINEMATIC,
}: }:
connected_output_node_ids = _connected_node_ids_by_step( connected_output_node_ids = _connected_node_ids_by_step(
workflow_context, workflow_context,
@@ -1015,6 +1076,8 @@ def _build_task_kwargs(
def _artifact_kind_override_for_step(step: StepName) -> str | None: def _artifact_kind_override_for_step(step: StepName) -> str | None:
if step == StepName.BLENDER_TURNTABLE: if step == StepName.BLENDER_TURNTABLE:
return "turntable_video" return "turntable_video"
if step == StepName.BLENDER_CINEMATIC:
return "cinematic_video"
if step == StepName.BLENDER_STILL: if step == StepName.BLENDER_STILL:
return "still_image" return "still_image"
if step == StepName.EXPORT_BLEND: if step == StepName.EXPORT_BLEND:
@@ -1019,6 +1019,165 @@ _NODE_DEFINITIONS: list[WorkflowNodeDefinition] = [
artifact_roles_consumed=["order_line_context", "render_template", "material_assignments", "bbox"], artifact_roles_consumed=["order_line_context", "render_template", "material_assignments", "bbox"],
artifact_roles_produced=["rendered_frames", "rendered_video"], artifact_roles_produced=["rendered_frames", "rendered_video"],
), ),
_definition(
StepName.BLENDER_CINEMATIC,
"Render Cinematic",
"order_line",
"render.production.cinematic",
"rendering",
"Render a cinematic highlight animation with Blender (250 frames @ 25 fps, procedural 4-segment camera path).",
node_type="renderFramesNode",
icon="film",
defaults={"use_custom_render_settings": False},
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",
"select",
description="Renderer backend for the cinematic render.",
section="Render",
default="cycles",
options=_BLENDER_ENGINE_OPTIONS,
),
_field(
"cycles_device",
"Cycles Device",
"select",
description="Force CPU, GPU, or automatic device selection.",
section="Render",
default="gpu",
options=_CYCLES_DEVICE_OPTIONS,
),
_field(
"samples",
"Samples",
"number",
description="Quality samples for each frame.",
section="Render",
default=128,
min=1,
max=4096,
step=1,
),
_field("width", "Width", "number", section="Output", default=1920, min=64, max=8192, step=1, unit="px"),
_field("height", "Height", "number", section="Output", default=1080, min=64, max=8192, step=1, unit="px"),
_field(
"transparent_bg",
"Transparent Background",
"boolean",
description="Render with alpha output for each frame.",
section="Output",
default=False,
),
_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",
"number",
description="Additional Z-axis rotation in degrees.",
section="Camera",
default=0,
min=-360,
max=360,
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_video"]},
artifact_roles_consumed=["order_line_context", "render_template", "material_assignments", "bbox"],
artifact_roles_produced=["rendered_video"],
),
_definition( _definition(
StepName.OUTPUT_SAVE, StepName.OUTPUT_SAVE,
"Save Output", "Save Output",