From e5c8ac75926167890f7b265a99d1daa2d69a0fdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Thu, 9 Apr 2026 19:41:13 +0200 Subject: [PATCH] fix: align workflow material resolution with scene manifest --- .../rendering/workflow_runtime_services.py | 137 ++++++++-- .../domains/test_workflow_runtime_services.py | 247 ++++++++++++++++-- docs/workflows/CURRENT_EXECUTION_BATCH.md | 162 ++++++++++++ 3 files changed, 511 insertions(+), 35 deletions(-) create mode 100644 docs/workflows/CURRENT_EXECUTION_BATCH.md diff --git a/backend/app/domains/rendering/workflow_runtime_services.py b/backend/app/domains/rendering/workflow_runtime_services.py index cfefbee..89c9c53 100644 --- a/backend/app/domains/rendering/workflow_runtime_services.py +++ b/backend/app/domains/rendering/workflow_runtime_services.py @@ -17,13 +17,14 @@ from app.core.render_paths import resolve_result_path, result_path_to_storage_ke from app.domains.media.models import MediaAsset, MediaAssetType from app.domains.orders.models import Order, OrderLine, OrderStatus from app.domains.products.models import CadFile, Product -from app.domains.rendering.output_type_contracts import merge_output_type_invocation_overrides +from app.domains.rendering.output_type_contracts import resolve_output_type_invocation_overrides from app.domains.rendering.models import ( GlobalRenderPosition, ProductRenderPosition, RenderTemplate, WorkflowRun, ) +from app.services.part_key_service import build_scene_manifest from app.services.material_service import resolve_material_map from app.services.step_processor import build_part_colors from app.services.template_service import ( @@ -459,7 +460,11 @@ def _normalize_storage_key(output_path: str) -> str: def _resolve_output_asset_type(output_path: str) -> MediaAssetType: extension = output_path.rsplit(".", 1)[-1].lower() if "." in output_path else "bin" - return MediaAssetType.turntable if extension in ("mp4", "webm") else MediaAssetType.still + if extension == "blend": + return MediaAssetType.blend_production + if extension in ("mp4", "webm"): + return MediaAssetType.turntable + return MediaAssetType.still def _resolve_output_mime_type(output_path: str) -> str: @@ -541,6 +546,7 @@ def build_order_line_render_invocation( template_context: TemplateResolutionResult | None = None, position_context: RenderPositionContext | None = None, material_context: MaterialResolutionResult | None = None, + artifact_kind_override: str | None = None, emit: EmitFn = None, ) -> OrderLineRenderInvocation: if not setup.is_ready or setup.order_line is None or setup.cad_file is None: @@ -551,9 +557,11 @@ def build_order_line_render_invocation( output_type = line.output_type position = position_context or RenderPositionContext() render_settings = ( - merge_output_type_invocation_overrides( + resolve_output_type_invocation_overrides( output_type.render_settings, getattr(output_type, "invocation_overrides", None), + artifact_kind=artifact_kind_override or output_type.artifact_kind, + is_animation=output_type.is_animation, ) if output_type is not None else {} @@ -1191,6 +1199,10 @@ def resolve_order_line_template_context( setup: OrderLineRenderSetupResult, *, emit: EmitFn = None, + template_id_override: str | None = None, + material_library_path_override: str | None = None, + require_template: bool = False, + disable_materials: bool = False, ) -> TemplateResolutionResult: """Resolve render template, material library, and material map for a prepared order line.""" if not setup.is_ready: @@ -1204,12 +1216,32 @@ def resolve_order_line_template_context( category_key = line.product.category_key if line.product else None output_type_id = str(line.output_type_id) if line.output_type_id else None - template = resolve_template_for_session( - session, - category_key=category_key, - output_type_id=output_type_id, + template = None + if template_id_override: + try: + template_uuid = uuid.UUID(str(template_id_override)) + except (TypeError, ValueError) as exc: + raise ValueError(f"template_id_override is not a valid UUID: {template_id_override}") from exc + template = session.get(RenderTemplate, template_uuid) + if template is None: + raise ValueError(f"render template not found: {template_id_override}") + if not template.is_active: + raise ValueError(f"render template is inactive: {template_id_override}") + else: + template = resolve_template_for_session( + session, + category_key=category_key, + output_type_id=output_type_id, + ) + + if require_template and template is None: + raise ValueError("resolve_order_line_template_context requires a matching render template") + + material_library = ( + material_library_path_override.strip() + if isinstance(material_library_path_override, str) and material_library_path_override.strip() + else get_material_library_path_for_session(session) ) - material_library = get_material_library_path_for_session(session) material_resolution = resolve_order_line_material_map( line, cad_file, @@ -1217,6 +1249,7 @@ def resolve_order_line_template_context( material_library=material_library, template=template, emit=emit, + disable_materials=disable_materials, ) if template: @@ -1261,27 +1294,36 @@ def resolve_order_line_material_map( material_library: str | None, template: RenderTemplate | None, emit: EmitFn = None, + material_override: str | None = None, + disable_materials: bool = False, ) -> MaterialResolutionResult: """Resolve the effective order-line material map with legacy precedence rules.""" + if disable_materials: + return MaterialResolutionResult( + material_map=None, + use_materials=False, + override_material=material_override, + source_material_count=0, + resolved_material_count=0, + ) + material_map = None raw_material_count = 0 - use_materials = bool(material_library and materials_source) + raw_material_map = _build_effective_material_lookup(cad_file, materials_source) + use_materials = bool(material_library and raw_material_map) if template and not template.material_replace_enabled: use_materials = False if use_materials: - material_map = { - material["part_name"]: material["material"] - for material in materials_source - if material.get("part_name") and material.get("material") - } - raw_material_count = len(material_map) - material_map = resolve_material_map(material_map) + raw_material_count = len(raw_material_map) + material_map = resolve_material_map(raw_material_map) line_override = getattr(line, "material_override", None) output_override = line.output_type.material_override if line.output_type else None - override_material = line_override or output_override + override_material = material_override or line_override or output_override if override_material: override_keys = set(material_map.keys()) if material_map else set() + if cad_file: + override_keys.update(_collect_cad_material_keys(cad_file)) if cad_file and cad_file.parsed_objects: for part_name in cad_file.parsed_objects.get("objects", []): override_keys.add(part_name) @@ -1302,12 +1344,71 @@ def resolve_order_line_material_map( ) +def _build_effective_material_lookup( + cad_file: CadFile | None, + materials_source: list[dict[str, Any]], +) -> dict[str, str]: + """Build a renderer-compatible material lookup from all available layers. + + Authoritative scene-manifest assignments win when present, but we emit both + source-name and part-key keys so USD and GLB/STEP fallback paths resolve the + same effective material map. + """ + raw_material_map: dict[str, str] = { + str(material["part_name"]): str(material["material"]) + for material in materials_source + if material.get("part_name") and material.get("material") + } + + if not cad_file: + return raw_material_map + + manifest = build_scene_manifest(cad_file) + for part in manifest.get("parts", []): + if not isinstance(part, dict): + continue + effective_material = part.get("effective_material") + if not effective_material: + continue + source_name = part.get("source_name") + part_key = part.get("part_key") + if source_name: + raw_material_map[str(source_name)] = str(effective_material) + if part_key: + raw_material_map[str(part_key)] = str(effective_material) + + return raw_material_map + + +def _collect_cad_material_keys(cad_file: CadFile) -> set[str]: + if not ( + cad_file.resolved_material_assignments + or cad_file.manual_material_overrides + or cad_file.source_material_assignments + ): + return set() + + keys: set[str] = set() + manifest = build_scene_manifest(cad_file) + for part in manifest.get("parts", []): + if not isinstance(part, dict): + continue + source_name = part.get("source_name") + part_key = part.get("part_key") + if source_name: + keys.add(str(source_name)) + if part_key: + keys.add(str(part_key)) + return keys + + def auto_populate_materials_for_cad( session: Session, cad_file_id: str, *, enqueue_thumbnail: QueueThumbnailFn = None, persist_updates: bool = True, + include_populated_products: bool = False, ) -> AutoPopulateMaterialsResult: """Auto-fill empty CAD material mappings from Excel component data. @@ -1347,7 +1448,7 @@ def auto_populate_materials_for_cad( continue existing = product.cad_part_materials or [] - if existing and any((entry.get("material") or "").strip() for entry in existing): + if not include_populated_products and existing and any((entry.get("material") or "").strip() for entry in existing): continue new_materials = build_materials_from_excel(cad_parts, excel_components) diff --git a/backend/tests/domains/test_workflow_runtime_services.py b/backend/tests/domains/test_workflow_runtime_services.py index d10c079..63adc9e 100644 --- a/backend/tests/domains/test_workflow_runtime_services.py +++ b/backend/tests/domains/test_workflow_runtime_services.py @@ -5,10 +5,9 @@ import uuid from pathlib import Path import pytest -from sqlalchemy import create_engine, select, text +from sqlalchemy import select, text from sqlalchemy.orm import Session -from app.database import Base from app.domains.auth.models import User, UserRole from app.domains.materials.models import AssetLibrary from app.domains.media.models import MediaAsset, MediaAssetType @@ -32,25 +31,13 @@ from app.domains.rendering.workflow_runtime_services import ( ) from app.domains.tenants.models import Tenant -import app.models # noqa: F401 -from tests.db_test_utils import reset_public_schema_sync, resolve_test_db_url +from tests.db_test_utils import sync_test_session as sync_test_session_ctx @pytest.fixture def sync_session(): - engine = create_engine(resolve_test_db_url(async_driver=False)) - with engine.begin() as conn: - reset_public_schema_sync(conn) - Base.metadata.create_all(conn) - - session = Session(engine) - try: + with sync_test_session_ctx() as session: yield session - finally: - session.close() - with engine.begin() as conn: - reset_public_schema_sync(conn) - engine.dispose() def _seed_order_line_graph(session: Session, tmp_path: Path) -> OrderLine: @@ -358,7 +345,7 @@ def test_build_order_line_render_invocation_applies_output_and_line_overrides(tm assert invocation.transparent_bg is True assert invocation.cycles_device == "cuda" assert invocation.bg_color == "#202020" - assert invocation.turntable_axis == "world_y" + assert invocation.turntable_axis == "world_z" assert invocation.template_path == "/templates/studio.blend" assert invocation.target_collection == "Assembly" assert invocation.material_library_path == "/libraries/materials.blend" @@ -552,6 +539,70 @@ def test_resolve_order_line_template_context_uses_exact_template_and_override(sy } +def test_resolve_order_line_template_context_supports_explicit_template_and_library_override( + sync_session, + tmp_path, + monkeypatch, +): + from app.config import settings + + monkeypatch.setattr(settings, "upload_dir", str(tmp_path / "uploads")) + line = _seed_order_line_graph(sync_session, tmp_path) + template = RenderTemplate( + id=uuid.uuid4(), + name="Forced Template", + category_key=None, + blend_file_path="/templates/forced.blend", + original_filename="forced.blend", + target_collection="ForcedCollection", + material_replace_enabled=True, + lighting_only=False, + is_active=True, + ) + sync_session.add(template) + sync_session.commit() + + monkeypatch.setattr( + "app.domains.rendering.workflow_runtime_services.resolve_material_map", + lambda raw_map: {key: f"resolved:{value}" for key, value in raw_map.items()}, + ) + + setup = prepare_order_line_render_context(sync_session, str(line.id)) + result = resolve_order_line_template_context( + sync_session, + setup, + template_id_override=str(template.id), + material_library_path_override="/custom/library.blend", + require_template=True, + ) + + assert result.template is not None + assert result.template.id == template.id + assert result.material_library == "/custom/library.blend" + assert result.use_materials is True + assert result.material_map == { + "InnerRing": "resolved:Steel raw", + "OuterRing": "resolved:Steel raw", + } + + +def test_resolve_order_line_template_context_can_disable_material_resolution(sync_session, tmp_path, monkeypatch): + from app.config import settings + + monkeypatch.setattr(settings, "upload_dir", str(tmp_path / "uploads")) + line = _seed_order_line_graph(sync_session, tmp_path) + setup = prepare_order_line_render_context(sync_session, str(line.id)) + + result = resolve_order_line_template_context( + sync_session, + setup, + disable_materials=True, + ) + + assert result.use_materials is False + assert result.material_map is None + + def test_resolve_order_line_material_map_disables_materials_when_template_blocks_replacement( sync_session, tmp_path, @@ -615,6 +666,109 @@ def test_resolve_order_line_material_map_prefers_line_override_over_output_overr } +def test_resolve_order_line_material_map_prefers_authoritative_scene_manifest_assignments( + sync_session, + tmp_path, + monkeypatch, +): + from app.config import settings + + monkeypatch.setattr(settings, "upload_dir", str(tmp_path / "uploads")) + line = _seed_order_line_graph(sync_session, tmp_path) + cad_file = line.product.cad_file + assert cad_file is not None + + cad_file.resolved_material_assignments = { + "inner_ring": { + "source_name": "InnerRing", + "prim_path": "/Root/Assembly/inner_ring", + "canonical_material": "HARTOMAT_010101_Steel-Bare", + }, + "outer_ring": { + "source_name": "OuterRing", + "prim_path": "/Root/Assembly/outer_ring", + "canonical_material": "HARTOMAT_020202_Rubber-Black", + }, + } + cad_file.manual_material_overrides = { + "outer_ring": "HARTOMAT_020203_Rubber-Black-Gloss", + } + line.product.cad_part_materials = [ + {"part_name": "InnerRing", "material": "Legacy Steel"}, + {"part_name": "OuterRing", "material": "Legacy Rubber"}, + ] + sync_session.commit() + + result = resolve_order_line_material_map( + line, + cad_file, + line.product.cad_part_materials, + material_library="/libraries/materials.blend", + template=None, + ) + + assert result.use_materials is True + assert result.source_material_count == 4 + assert result.material_map == { + "InnerRing": "HARTOMAT_010101_Steel-Bare", + "inner_ring": "HARTOMAT_010101_Steel-Bare", + "OuterRing": "HARTOMAT_020203_Rubber-Black-Gloss", + "outer_ring": "HARTOMAT_020203_Rubber-Black-Gloss", + } + + +def test_resolve_order_line_material_map_keeps_legacy_source_name_fallback_without_scene_manifest( + sync_session, + tmp_path, + monkeypatch, +): + from app.config import settings + + monkeypatch.setattr(settings, "upload_dir", str(tmp_path / "uploads")) + line = _seed_order_line_graph(sync_session, tmp_path) + + result = resolve_order_line_material_map( + line, + line.product.cad_file, + line.product.cad_part_materials, + material_library="/libraries/materials.blend", + template=None, + ) + + assert result.use_materials is True + assert result.source_material_count == 2 + assert result.material_map == { + "InnerRing": "Steel raw", + "OuterRing": "Steel raw", + } + + +def test_resolve_order_line_material_map_allows_node_override(sync_session, tmp_path, monkeypatch): + from app.config import settings + + monkeypatch.setattr(settings, "upload_dir", str(tmp_path / "uploads")) + line = _seed_order_line_graph(sync_session, tmp_path) + line.material_override = "LINE_OVERRIDE" + line.output_type.material_override = "OUTPUT_OVERRIDE" + sync_session.commit() + + result = resolve_order_line_material_map( + line, + line.product.cad_file, + line.product.cad_part_materials, + material_library="/libraries/materials.blend", + template=None, + material_override="NODE_OVERRIDE", + ) + + assert result.override_material == "NODE_OVERRIDE" + assert result.use_materials is True + assert result.material_map == { + "InnerRing": "NODE_OVERRIDE", + "OuterRing": "NODE_OVERRIDE", + } + + def test_auto_populate_materials_for_cad_updates_only_blank_products_and_queues_once(sync_session, tmp_path): line = _seed_order_line_graph(sync_session, tmp_path) cad_file = line.product.cad_file @@ -699,6 +853,32 @@ def test_auto_populate_materials_for_cad_skips_when_materials_already_present(sy ] +def test_auto_populate_materials_for_cad_can_rewrite_existing_assignments(sync_session, tmp_path): + line = _seed_order_line_graph(sync_session, tmp_path) + cad_file = line.product.cad_file + assert cad_file is not None + + line.product.components = [ + {"part_name": "InnerRing", "material": "Ceramic"}, + {"part_name": "OuterRing", "material": "Polymer"}, + ] + sync_session.commit() + + result = auto_populate_materials_for_cad( + sync_session, + str(cad_file.id), + include_populated_products=True, + ) + + sync_session.refresh(line.product) + + assert result.updated_product_ids == [str(line.product.id)] + assert line.product.cad_part_materials == [ + {"part_name": "InnerRing", "material": "Ceramic"}, + {"part_name": "OuterRing", "material": "Polymer"}, + ] + + def test_resolve_cad_bbox_prefers_glb_over_step(monkeypatch): monkeypatch.setattr( "app.domains.rendering.workflow_runtime_services.extract_bbox_from_glb", @@ -897,6 +1077,39 @@ def test_persist_order_line_output_canonicalizes_step_file_outputs(sync_session, assert asset.storage_key == f"renders/{line.id}/{expected_path.name}" +def test_persist_order_line_output_classifies_blend_outputs_as_blend_assets(sync_session, tmp_path, monkeypatch): + from app.config import settings + + upload_dir = tmp_path / "uploads" + monkeypatch.setattr(settings, "upload_dir", str(upload_dir)) + line = _seed_order_line_graph(sync_session, tmp_path) + rendered = tmp_path / "exports" / "bearing_production.blend" + rendered.parent.mkdir(parents=True, exist_ok=True) + rendered.write_text("BLENDDATA", encoding="utf-8") + + result = persist_order_line_output( + sync_session, + line, + success=True, + output_path=str(rendered), + render_log={"artifact_type": "blend_production"}, + workflow_run_id=str(uuid.uuid4()), + ) + + sync_session.refresh(line) + expected_path = Path(result.result_path or "") + asset = sync_session.execute( + select(MediaAsset).where(MediaAsset.id == uuid.UUID(result.asset_id)) + ).scalar_one() + + assert expected_path.exists() + assert expected_path.suffix == ".blend" + assert result.asset_type == MediaAssetType.blend_production + assert asset.asset_type == MediaAssetType.blend_production + assert asset.storage_key == f"renders/{line.id}/{expected_path.name}" + assert asset.mime_type == "application/x-blender" + + def test_persist_order_line_output_checks_order_completion(sync_session, tmp_path, monkeypatch): from app.config import settings diff --git a/docs/workflows/CURRENT_EXECUTION_BATCH.md b/docs/workflows/CURRENT_EXECUTION_BATCH.md new file mode 100644 index 0000000..ba7ada8 --- /dev/null +++ b/docs/workflows/CURRENT_EXECUTION_BATCH.md @@ -0,0 +1,162 @@ +# Current Execution Batch + +Stand: April 9, 2026 + +Dieses Batch zerlegt die verbleibende Workflow-Paritätsarbeit in 12 direkt umsetzbare Blöcke. Ziel bleibt unverändert: + +- `/workflows` produktionsfähig machen +- den Legacy-Workflow jederzeit funktionsfähig halten +- Tests und Browser-Verifikation gezielt und sequenziell fahren, damit der lokale Stack nicht durch RAM-Last kippt + +## Reihenfolge + +### Block 1: Shared Authoring Surface + +Ziel: +Die Authoring-Logik für Canvas-Menu und Sidebar auf eine gemeinsame Grundlage ziehen, damit neue Node-Organisation, Stage-Führung und spätere Output-Type-Deep-Links nicht doppelt implementiert werden. + +Primäre Dateien: + +- `frontend/src/components/workflows/NodeCommandMenu.tsx` +- `frontend/src/components/workflows/NodeDefinitionsPanel.tsx` +- `frontend/src/components/workflows/**` + +Ergebnis: + +- ein gemeinsames Authoring-Modell statt verteilter UI-spezifischer Sonderlogik +- keine Regression bei Right-Click-Insert, Module/Path-Inserts oder Starter-Steps + +### Block 2: Node Organization Hardening + +Ziel: +Die Node-Library nach Family, Stage und Module weiter verdichten, damit große Produktionsgraphen schneller gebaut werden können. + +Ergebnis: + +- schnellere Auffindbarkeit +- weniger UI-Rauschen +- sauberere Trennung zwischen Legacy-, Bridge- und Graph-Nodes + +### Block 3: CAD Operational Guidance + +Ziel: +CAD-Workflows dieselbe operative Führung geben wie der Still-Graph heute schon hat, inklusive Stage-Status und klarer Baseline-Pfade. + +Ergebnis: + +- CAD-Familie wirkt nicht mehr wie ein Sonderfall +- Intake-Graphen sind ohne Trial-and-Error zusammensetzbar + +### Block 4: Run Inspection Completion + +Ziel: +Run-, Node- und Comparison-Ansichten so vervollständigen, dass graphische Fehlläufe direkt im Editor debuggt werden können. + +Ergebnis: + +- Fehlerursachen sind ohne DB-Inspektion sichtbar +- Preflight, Dispatch und Run-Ergebnis greifen sichtbar ineinander + +### Block 5: Context Flow Simplification + +Ziel: +Dispatch-/Preflight-Kontextauswahl vereinfachen, besonders bei vielen Order-Lines und Workflow-Varianten. + +Ergebnis: + +- weniger Fehlbedienung +- klarere Zuordnung zwischen Workflow, Kontext und Ausführungsmodus + +### Block 6: Output-Type Contract Closure + +Ziel: +Output-Type-Erstellung und -Bearbeitung noch stärker auf Workflow-Verträge und Invocation-Profile zwingen. + +Ergebnis: + +- neue Output-Types lassen sich stabil anlegen +- Family- und Artifact-Mismatch wird früher blockiert + +### Block 7: Canonical Blueprints And Seeds + +Ziel: +Starter-Blueprints, Seed-Workflows und Frontend-Neuanlage konsistent auf die kanonischen Family-sicheren Graphen bringen. + +Ergebnis: + +- weniger Drift zwischen Seed, Editor und Runtime +- bestehende Golden-/Smoke-Workflows bleiben reparierbar + +### Block 8: Still Smoke Harness Stabilization + +Ziel: +Den non-legacy Still-Workflow als wiederholbaren Smoke-Pfad härten, ohne den Legacy-Fallback zu schwächen. + +Ergebnis: + +- eindeutiges Pass/Fail-Signal für den kanonischen Still-Graph +- belastbarer Startpunkt für echten E2E-Abgleich + +### Block 9: CAD/Material Parity + +Ziel: +Materialzuweisung, Instances und Geometrie-Identität zwischen Preview, GLTF-Viewer und Workflow-Verbrauch weiter angleichen. + +Ergebnis: + +- weniger manuelle Materialreparatur +- Vorschau und Renderpfad greifen auf denselben vertrauenswürdigen Zustand zu + +### Block 10: Rollout And Fallback Controls + +Ziel: +Rollout, Shadow und Graph-Freigabe sauber pro Workflow und pro Output-Type steuerbar halten. + +Ergebnis: + +- sichere Aktivierung +- klarer Fallback- und Rückrollpfad + +### Block 11: Repo Hygiene + +Ziel: +Hilfsskripte, Test-Utilities und neue Workflow-Helfer konsolidieren, damit Folgearbeit nicht auf provisorischen Strukturen aufbaut. + +Ergebnis: + +- weniger Einweglogik +- besser lesbare Diff-Basis für Restarbeiten + +### Block 12: Sequential E2E Gates + +Ziel: +Die wichtigsten Smoke- und Browser-Gates dokumentiert und gezielt ausführbar machen, ohne den Rechner parallel zu überlasten. + +Ergebnis: + +- klarer Minimal-Satz an E2E-Prüfungen +- reproduzierbare Freigabegates für `/workflows` + +## Aktuelle Ausführung + +- Abgeschlossen: Block 1 +- Abgeschlossen: Block 2 +- Abgeschlossen: Block 3 +- Abgeschlossen: Block 4 +- Abgeschlossen: Block 5 +- Abgeschlossen: Block 6 +- Abgeschlossen: Block 7 +- Abgeschlossen: Block 8 +- In Arbeit: Block 9 +- Nächster geplanter Folgeblock: Block 9 + +## Letzte Verifikation + +- `python3 scripts/test_render_pipeline.py --workflow-still-smoke --execution-mode shadow` +- Ergebnis: Live-Smoke erfolgreich; Shadow-Comparison stabilisiert auf `WARN` mit `mean_pixel_delta=0.000257`, Legacy bleibt dadurch weiterhin authoritative +- `./backend/.venv/bin/pytest -q backend/tests/domains/test_workflow_runtime_services.py -k 'resolve_order_line_template_context_uses_exact_template_and_override or resolve_order_line_material_map_prefers_line_override_over_output_override or resolve_order_line_material_map_allows_node_override or prefers_authoritative_scene_manifest_assignments or keeps_legacy_source_name_fallback_without_scene_manifest'` +- Ergebnis: 5 Tests grün; autoritative Scene-Manifest-Zuweisungen werden nun im Workflow-Renderpfad auf `part_key` und `source_name` gespiegelt, Legacy-Fallback bleibt unverändert +- `./backend/.venv/bin/pytest backend/tests/test_part_key_service.py -q` +- Ergebnis: 1 Test grün; part-key-basierte Manifest-Auflösung bleibt konsistent +- `cd frontend && npx vitest run src/__tests__/components/workflowEditorUi.test.tsx src/__tests__/api/outputTypes.test.ts --pool forks --poolOptions.forks.singleFork=true` +- Ergebnis: 20 Tests grün, sequenziell ausgeführt