From 3401b06b19e56fbdd4cc2402ec328bf927ded6a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Wed, 22 Jul 2026 15:09:47 +0200 Subject: [PATCH] feat: BLENDER_CINEMATIC workflow graph node (M1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- LEARNINGS.md | 3 + backend/app/core/process_steps.py | 1 + backend/app/domains/rendering/tasks.py | 245 ++++++++++++++++++ .../domains/rendering/workflow_executor.py | 3 +- .../rendering/workflow_graph_runtime.py | 63 +++++ .../rendering/workflow_node_registry.py | 159 ++++++++++++ 6 files changed, 473 insertions(+), 1 deletion(-) diff --git a/LEARNINGS.md b/LEARNINGS.md index 03f377a..28721dd 100644 --- a/LEARNINGS.md +++ b/LEARNINGS.md @@ -7,6 +7,9 @@ ## 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 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`. diff --git a/backend/app/core/process_steps.py b/backend/app/core/process_steps.py index 6a7267f..d29d863 100644 --- a/backend/app/core/process_steps.py +++ b/backend/app/core/process_steps.py @@ -25,6 +25,7 @@ class StepName(StrEnum): RESOLVE_TEMPLATE = "resolve_template" BLENDER_STILL = "blender_still" BLENDER_TURNTABLE = "blender_turntable" + BLENDER_CINEMATIC = "blender_cinematic" OUTPUT_SAVE = "output_save" # ── Asset export ────────────────────────────────────────────────── diff --git a/backend/app/domains/rendering/tasks.py b/backend/app/domains/rendering/tasks.py index 6158569..683cc24 100644 --- a/backend/app/domains/rendering/tasks.py +++ b/backend/app/domains/rendering/tasks.py @@ -1631,6 +1631,251 @@ def render_order_line_still_task(self, order_line_id: str, **params) -> dict: 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( bind=True, name="app.domains.rendering.tasks.export_blend_for_order_line_task", diff --git a/backend/app/domains/rendering/workflow_executor.py b/backend/app/domains/rendering/workflow_executor.py index f858366..6d9487c 100644 --- a/backend/app/domains/rendering/workflow_executor.py +++ b/backend/app/domains/rendering/workflow_executor.py @@ -109,9 +109,10 @@ STEP_TASK_MAP: dict[StepName, str] = { # ── Thumbnail generation ───────────────────────────────────────────── StepName.BLENDER_RENDER: "app.tasks.step_tasks.render_step_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_TURNTABLE: "app.domains.rendering.tasks.render_turntable_task", + StepName.BLENDER_CINEMATIC: "app.domains.rendering.tasks.render_cinematic_task", # ── Asset export ───────────────────────────────────────────────────── StepName.EXPORT_BLEND: "app.domains.rendering.tasks.export_blend_for_order_line_task", # ── Steps without a dedicated standalone task (no mapping) ─────────── diff --git a/backend/app/domains/rendering/workflow_graph_runtime.py b/backend/app/domains/rendering/workflow_graph_runtime.py index f6b3bd8..d538ec3 100644 --- a/backend/app/domains/rendering/workflow_graph_runtime.py +++ b/backend/app/domains/rendering/workflow_graph_runtime.py @@ -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: diff --git a/backend/app/domains/rendering/workflow_node_registry.py b/backend/app/domains/rendering/workflow_node_registry.py index 476a1fb..2071f72 100644 --- a/backend/app/domains/rendering/workflow_node_registry.py +++ b/backend/app/domains/rendering/workflow_node_registry.py @@ -1019,6 +1019,165 @@ _NODE_DEFINITIONS: list[WorkflowNodeDefinition] = [ artifact_roles_consumed=["order_line_context", "render_template", "material_assignments", "bbox"], 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( StepName.OUTPUT_SAVE, "Save Output",