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:
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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 ──────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user