fix: cinematic output type guard + legacy_only migration

- Migration 070: seeds 'Cinematic Highlight' output type (legacy_only, no
  workflow link) for fresh installs
- Migration 071: patches existing cinematic output types — clears
  workflow_definition_id and forces legacy_only rollout mode. The row
  existed since 2026-03 with shadow mode + a linked workflow def;
  no BLENDER_CINEMATIC graph node exists so shadow execution would fail.
- API guard in output_types POST + PATCH: cinematic output types cannot
  receive a workflow_definition_id (HTTP 400)
- Defense-in-depth in dispatch_service: early legacy exit if
  render_settings.cinematic is true, regardless of rollout mode
- docs: learning erfasst — cinematic rollout mode footgun

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 10:04:07 +02:00
co-authored by Claude Sonnet 4.6
parent 971074fedd
commit 44b52d05cc
5 changed files with 136 additions and 0 deletions
@@ -0,0 +1,59 @@
"""Seed 'Cinematic Highlight' output type.
Revision ID: 070
Revises: 069
"""
from alembic import op
import sqlalchemy as sa
revision = "070"
down_revision = "069"
branch_labels = None
depends_on = None
_NAME = "Cinematic Highlight"
def upgrade() -> None:
op.execute(
sa.text(
f"""
INSERT INTO output_types (
id, name, description, renderer, render_settings,
output_format, sort_order, compatible_categories, render_backend,
is_animation, transparent_bg, workflow_family, artifact_kind,
invocation_overrides, cycles_device, pricing_tier_id, is_active,
tenant_id, material_override, workflow_definition_id,
workflow_rollout_mode, created_at, updated_at
) VALUES (
gen_random_uuid(),
'{_NAME}',
'Cinematic highlight animation: 4-segment camera orbit, depth-of-field, '
'250 frames @ 25 fps (10 s), 1920x1080 MP4. '
'Runs legacy-only — no workflow graph node exists for cinematic.',
'blender',
'{{"cinematic": true, "samples": 128, "width": 1920, "height": 1080}}'::jsonb,
'mp4',
50,
'[]'::jsonb,
'celery',
true,
false,
'order_line',
'turntable_video',
'{{}}'::jsonb,
NULL, NULL, true, NULL, NULL, NULL,
'legacy_only',
now(), now()
)
ON CONFLICT (name) DO NOTHING
"""
)
)
def downgrade() -> None:
op.execute(
sa.text("DELETE FROM output_types WHERE name = :name").bindparams(name=_NAME)
)
@@ -0,0 +1,35 @@
"""Fix cinematic output types: force legacy_only rollout, clear workflow link.
No BLENDER_CINEMATIC node exists in the workflow graph system. Any cinematic
output type with a workflow_definition_id set would cause the shadow/graph
execution path to attempt a graph run and fail silently.
Revision ID: 071
Revises: 070
"""
from alembic import op
import sqlalchemy as sa
revision = "071"
down_revision = "070"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
sa.text(
"""
UPDATE output_types
SET
workflow_rollout_mode = 'legacy_only',
workflow_definition_id = NULL
WHERE (render_settings->>'cinematic')::boolean IS TRUE
"""
)
)
def downgrade() -> None:
pass
+15
View File
@@ -271,6 +271,13 @@ async def create_output_type(
if body.workflow_definition_id is None:
data["workflow_rollout_mode"] = "legacy_only"
if data.get("render_settings", {}).get("cinematic") and body.workflow_definition_id is not None:
raise HTTPException(
400,
detail="Cinematic output types cannot be linked to a workflow definition. "
"No BLENDER_CINEMATIC node exists in the workflow graph; cinematic renders use the legacy path only.",
)
ot = OutputType(**data)
db.add(ot)
await db.commit()
@@ -387,6 +394,14 @@ async def update_output_type(
if candidate_workflow_definition_id is None:
data["workflow_rollout_mode"] = "legacy_only"
candidate_render_settings = data.get("render_settings", ot.render_settings) or {}
if candidate_render_settings.get("cinematic") and candidate_workflow_definition_id is not None:
raise HTTPException(
400,
detail="Cinematic output types cannot be linked to a workflow definition. "
"No BLENDER_CINEMATIC node exists in the workflow graph; cinematic renders use the legacy path only.",
)
for field_name, value in data.items():
setattr(ot, field_name, value)
await db.commit()
@@ -193,6 +193,30 @@ def dispatch_render_with_workflow(order_line_id: str) -> dict:
)
return legacy_result
# Cinematic output types have no BLENDER_CINEMATIC graph node — force legacy regardless
# of what workflow_rollout_mode or workflow_definition_id say.
if output_type and isinstance(output_type.render_settings, dict) and output_type.render_settings.get("cinematic"):
logger.warning(
"order_line %s: output_type %s is cinematic but has a workflow_definition_id set; "
"forcing legacy dispatch (no BLENDER_CINEMATIC node exists in the workflow graph)",
order_line_id,
output_type.id,
)
legacy_result = _legacy_dispatch(order_line_id)
legacy_result.update(
_build_rollout_signal(
gate_status="cinematic_legacy_only",
ready=False,
reasons=[
"Cinematic output types always use the legacy dispatch path.",
"Remove the workflow_definition_id link to silence this warning.",
],
workflow_def_id=wf_def.id,
output_type_id=output_type.id,
)
)
return legacy_result
configured_execution_mode = get_workflow_execution_mode(canonical_config, default="legacy")
workflow_rollout_mode = _normalize_workflow_rollout_mode(
getattr(output_type, "workflow_rollout_mode", None)