chore: snapshot workflow migration progress

This commit is contained in:
2026-04-12 11:49:04 +02:00
parent 0cd02513d5
commit 3e810c74a3
163 changed files with 31774 additions and 2753 deletions
@@ -5,7 +5,7 @@ import re
import shutil
import uuid
from dataclasses import dataclass, field
from datetime import datetime
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable, Literal
@@ -13,7 +13,11 @@ from sqlalchemy import select, update as sql_update
from sqlalchemy.orm import Session, joinedload
from app.config import settings as app_settings
from app.core.render_paths import resolve_result_path, result_path_to_storage_key
from app.core.render_paths import (
ensure_group_writable_dir,
resolve_result_path,
result_path_to_storage_key,
)
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
@@ -37,6 +41,199 @@ logger = logging.getLogger(__name__)
EmitFn = Callable[..., None] | None
SetupStatus = Literal["ready", "skip", "failed", "missing"]
QueueThumbnailFn = Callable[[str, dict[str, str]], None] | None
TEMPLATE_INPUT_PARAM_PREFIX = "template_input__"
_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
_VOLATILE_PNG_CHUNK_TYPES = {b"tEXt", b"zTXt", b"iTXt", b"tIME"}
def _slugify_material_lookup_key(value: str) -> str:
return re.sub(r"[^a-z0-9]+", "_", value).strip("_")
def _build_authoritative_material_lookup(materials_source: list[dict[str, Any]]) -> dict[str, str]:
lookup: dict[str, str] = {}
for material in materials_source:
raw_part_name = material.get("part_name")
raw_material_name = material.get("material")
if not raw_part_name or not raw_material_name:
continue
part_name = str(raw_part_name).lower().strip()
material_name = str(raw_material_name)
if not part_name:
continue
lookup.setdefault(part_name, material_name)
slug_key = _slugify_material_lookup_key(part_name)
if slug_key:
lookup.setdefault(slug_key, material_name)
stripped = re.sub(r"(_af\d+(_\d+)?)+$", "", part_name, flags=re.IGNORECASE)
if stripped != part_name:
lookup.setdefault(stripped, material_name)
slug_stripped = _slugify_material_lookup_key(stripped)
if slug_stripped:
lookup.setdefault(slug_stripped, material_name)
return lookup
def _common_prefix_length(left: str, right: str) -> int:
limit = min(len(left), len(right))
idx = 0
while idx < limit and left[idx] == right[idx]:
idx += 1
return idx
def _lookup_material_by_prefix(query: str, material_lookup: dict[str, str]) -> str | None:
if not query or not material_lookup:
return None
contenders: list[tuple[int, str]] = []
for key, material_name in material_lookup.items():
if len(key) >= 5 and len(query) >= 5 and (query.startswith(key) or key.startswith(query)):
contenders.append((len(key), material_name))
if not contenders:
return None
contenders.sort(reverse=True)
top_length = contenders[0][0]
close_materials = {
material_name
for key_length, material_name in contenders
if key_length >= top_length - 2
}
return contenders[0][1] if len(close_materials) == 1 else None
def _lookup_material_by_common_prefix(query: str, material_lookup: dict[str, str]) -> str | None:
if not query or not material_lookup:
return None
scored: list[tuple[float, int, int, str]] = []
for key, material_name in material_lookup.items():
prefix_length = _common_prefix_length(query, key)
if prefix_length < 12:
continue
ratio = prefix_length / max(len(query), len(key))
if ratio < 0.68:
continue
scored.append((ratio, prefix_length, len(key), material_name))
if not scored:
return None
scored.sort(reverse=True)
top_ratio, top_prefix_length, _, top_material_name = scored[0]
close_materials = {
material_name
for ratio, prefix_length, _, material_name in scored
if ratio >= top_ratio - 0.02 and prefix_length >= top_prefix_length - 2
}
return top_material_name if len(close_materials) == 1 else None
def _resolve_authoritative_material_name(
raw_name: str | None,
material_lookup: dict[str, str],
*fallback_names: str | None,
) -> str | None:
candidates = [raw_name, *fallback_names]
seen: set[str] = set()
for candidate in candidates:
if not candidate:
continue
normalized = str(candidate).lower().strip()
variants = [normalized]
stripped = re.sub(r"(_af\d+(_\d+)?)+$", "", normalized, flags=re.IGNORECASE)
if stripped != normalized:
variants.append(stripped)
no_instance = re.sub(r"_\d+$", "", stripped)
if no_instance and no_instance not in variants:
variants.append(no_instance)
for variant in list(variants):
slug_variant = _slugify_material_lookup_key(variant)
if slug_variant and slug_variant not in variants:
variants.append(slug_variant)
deduped_variants = [variant for variant in variants if variant and not (variant in seen or seen.add(variant))]
for variant in deduped_variants:
material_name = material_lookup.get(variant)
if material_name:
return material_name
for variant in deduped_variants:
material_name = _lookup_material_by_prefix(variant, material_lookup)
if material_name:
return material_name
for variant in deduped_variants:
material_name = _lookup_material_by_common_prefix(variant, material_lookup)
if material_name:
return material_name
return None
def _utcnow_naive() -> datetime:
"""Return UTC as a naive datetime for legacy TIMESTAMP WITHOUT TIME ZONE columns."""
return datetime.now(timezone.utc).replace(tzinfo=None)
def extract_template_input_overrides(params: dict[str, Any] | None) -> dict[str, Any]:
if not params:
return {}
overrides: dict[str, Any] = {}
for key, value in params.items():
if not isinstance(key, str) or not key.startswith(TEMPLATE_INPUT_PARAM_PREFIX):
continue
input_key = key[len(TEMPLATE_INPUT_PARAM_PREFIX):].strip()
if input_key:
overrides[input_key] = value
return overrides
def _normalize_template_input_schema(template: RenderTemplate | None) -> list[dict[str, Any]]:
raw_schema = getattr(template, "workflow_input_schema", None) if template is not None else None
if not isinstance(raw_schema, list):
return []
normalized: list[dict[str, Any]] = []
for raw_field in raw_schema:
if not isinstance(raw_field, dict):
continue
key = str(raw_field.get("key") or "").strip()
if not key:
continue
normalized.append(dict(raw_field))
return normalized
def _resolve_template_input_values(
schema: list[dict[str, Any]],
overrides: dict[str, Any] | None,
) -> dict[str, Any]:
raw_overrides = overrides or {}
resolved: dict[str, Any] = {}
for field in schema:
key = str(field.get("key") or "").strip()
if not key:
continue
if key in raw_overrides:
resolved[key] = raw_overrides[key]
continue
if "default" in field:
resolved[key] = field.get("default")
return resolved
@dataclass(slots=True)
@@ -75,8 +272,14 @@ class TemplateResolutionResult:
material_map: dict[str, str] | None
use_materials: bool
override_material: str | None
target_collection: str
lighting_only: bool
shadow_catcher: bool
camera_orbit: bool
category_key: str | None
output_type_id: str | None
workflow_input_schema: list[dict[str, Any]] = field(default_factory=list)
template_inputs: dict[str, Any] = field(default_factory=dict)
@dataclass(slots=True)
@@ -159,6 +362,7 @@ class OrderLineRenderInvocation:
sensor_width_mm: float | None = None
usd_path: str | None = None
material_override: str | None = None
template_inputs: dict[str, Any] = field(default_factory=dict)
def task_defaults(self) -> dict[str, Any]:
payload: dict[str, Any] = {
@@ -196,9 +400,10 @@ class OrderLineRenderInvocation:
"sensor_width_mm": self.sensor_width_mm,
"usd_path": self.usd_path,
"material_override": self.material_override,
"template_inputs": self.template_inputs,
}
for key, value in optional_values.items():
if value not in (None, ""):
if value not in (None, "", {}, [], ()):
payload[key] = value
return payload
@@ -242,6 +447,7 @@ class OrderLineRenderInvocation:
"focal_length_mm": self.focal_length_mm,
"sensor_width_mm": self.sensor_width_mm,
"material_override": self.material_override,
"template_inputs": self.template_inputs,
}
def as_turntable_renderer_kwargs(
@@ -285,6 +491,7 @@ class OrderLineRenderInvocation:
"focal_length_mm": self.focal_length_mm,
"sensor_width_mm": self.sensor_width_mm,
"material_override": self.material_override,
"template_inputs": self.template_inputs,
}
def as_cinematic_renderer_kwargs(
@@ -324,6 +531,7 @@ class OrderLineRenderInvocation:
"focal_length_mm": self.focal_length_mm,
"sensor_width_mm": self.sensor_width_mm,
"material_override": self.material_override,
"template_inputs": self.template_inputs,
"log_callback": log_callback,
}
@@ -341,7 +549,61 @@ def _resolve_asset_path(storage_key: str | None) -> Path | None:
return resolve_result_path(storage_key)
def _usd_master_refresh_reason(cad_file: CadFile) -> str | None:
def _usd_master_file_refresh_reason(usd_render_path: Path | None) -> str | None:
if usd_render_path is None:
return "missing USD master file"
if not usd_render_path.exists():
return "missing USD master file"
try:
usd_bytes = usd_render_path.read_bytes()
except OSError:
logger.exception("render_order_line: failed to inspect usd_master %s", usd_render_path)
return "unreadable USD master file"
usd_bytes_lower = usd_bytes.lower()
if b"schaeffler:" in usd_bytes_lower:
return "legacy Schaeffler USD primvars"
if b"hartomat:" in usd_bytes_lower:
return None
# Binary USD (`PXR-USDC`) stores HartOMat customData in a form that is not
# reliably discoverable via a raw byte grep. For those files we rely on the
# cache fingerprint plus the upstream resolved material metadata checks.
if usd_bytes.startswith(b"PXR-USDC") or b"\x00" in usd_bytes[:256]:
return None
# Textual USD payloads without any HartOMat markers are legacy/stale in the
# current pipeline and should be refreshed before they are reused.
try:
usd_bytes.decode("utf-8")
except UnicodeDecodeError:
return None
return "missing HartOMat USD markers"
def _usd_master_cache_refresh_reason(usd_asset: MediaAsset | None) -> str | None:
if usd_asset is None:
return None
render_config = usd_asset.render_config if isinstance(usd_asset.render_config, dict) else {}
cache_key = render_config.get("cache_key")
if not isinstance(cache_key, str) or not cache_key.strip():
return "missing USD cache fingerprint"
# New-format keys append the render-script fingerprint as a sixth colon-delimited segment.
if len(cache_key.split(":")) < 6:
return "legacy USD cache fingerprint"
return None
def _usd_master_refresh_reason(
cad_file: CadFile,
*,
usd_asset: MediaAsset | None = None,
usd_render_path: Path | None = None,
) -> str | None:
resolved = cad_file.resolved_material_assignments
if not isinstance(resolved, dict) or not resolved:
return "missing resolved material assignments"
@@ -350,7 +612,7 @@ def _usd_master_refresh_reason(cad_file: CadFile) -> str | None:
for meta in resolved.values():
if not isinstance(meta, dict):
continue
canonical = meta.get("canonical_material")
canonical = meta.get("canonical_material") or meta.get("material")
if isinstance(canonical, str) and canonical.strip():
canonical_materials.append(canonical.strip())
@@ -360,6 +622,14 @@ def _usd_master_refresh_reason(cad_file: CadFile) -> str | None:
if any(material.upper().startswith("SCHAEFFLER_") for material in canonical_materials):
return "legacy Schaeffler material metadata"
cache_reason = _usd_master_cache_refresh_reason(usd_asset)
if cache_reason is not None:
return cache_reason
file_reason = _usd_master_file_refresh_reason(usd_render_path)
if file_reason is not None:
return file_reason
return None
@@ -502,6 +772,27 @@ def _coerce_bool(value: Any) -> bool:
return bool(value)
def _resolve_tristate_mode(
value: Any,
*,
field_name: str,
fallback: bool | None = None,
) -> bool | None:
if value in (None, "", "inherit"):
return fallback
if isinstance(value, bool):
return value
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"enabled", "true", "1", "yes", "on"}:
return True
if normalized in {"disabled", "false", "0", "no", "off"}:
return False
raise ValueError(
f"{field_name} must be one of: inherit, enabled, disabled"
)
def _resolve_render_output_extension(line: OrderLine) -> str:
output_type = line.output_type
output_extension = "jpg"
@@ -582,7 +873,7 @@ def build_order_line_render_invocation(
denoising_quality = str(render_settings.get("denoising_quality", ""))
denoising_use_gpu = str(render_settings.get("denoising_use_gpu", ""))
transparent_bg = bool(output_type and output_type.transparent_bg)
cycles_device = (output_type.cycles_device or "auto") if output_type is not None else "auto"
cycles_device = (output_type.cycles_device or "gpu") if output_type is not None else "gpu"
render_overrides = getattr(line, "render_overrides", None)
if isinstance(render_overrides, dict):
@@ -682,22 +973,14 @@ def build_order_line_render_invocation(
part_colors=dict(setup.part_colors or {}),
part_names_ordered=part_names_ordered,
template_path=template_context.template.blend_file_path if template_context and template_context.template else None,
target_collection=(
template_context.template.target_collection
if template_context and template_context.template and template_context.template.target_collection
else "Product"
),
target_collection=template_context.target_collection if template_context else "Product",
material_library_path=(
template_context.material_library if template_context and use_materials else None
),
material_map=material_map,
lighting_only=bool(template_context.template.lighting_only) if template_context and template_context.template else False,
shadow_catcher=(
bool(template_context.template.shadow_catcher_enabled)
if template_context and template_context.template
else False
),
camera_orbit=bool(template_context.template.camera_orbit) if template_context and template_context.template else True,
lighting_only=template_context.lighting_only if template_context else False,
shadow_catcher=template_context.shadow_catcher if template_context else False,
camera_orbit=template_context.camera_orbit if template_context else True,
rotation_x=position.rotation_x,
rotation_y=position.rotation_y,
rotation_z=position.rotation_z,
@@ -705,6 +988,7 @@ def build_order_line_render_invocation(
sensor_width_mm=position.sensor_width_mm,
usd_path=str(setup.usd_render_path) if setup.usd_render_path is not None else None,
material_override=material_override,
template_inputs=dict(template_context.template_inputs) if template_context is not None else {},
)
@@ -727,10 +1011,49 @@ def _canonical_public_output_path(line: OrderLine, output_path: str) -> str:
return str(upload_root / "renders" / str(line.id) / filename)
def _strip_volatile_png_metadata(output_path: Path) -> None:
if output_path.suffix.lower() != ".png" or not output_path.is_file():
return
raw_bytes = output_path.read_bytes()
if not raw_bytes.startswith(_PNG_SIGNATURE):
return
cursor = len(_PNG_SIGNATURE)
kept_chunks: list[bytes] = []
changed = False
while cursor + 12 <= len(raw_bytes):
chunk_length = int.from_bytes(raw_bytes[cursor : cursor + 4], "big")
chunk_end = cursor + 12 + chunk_length
if chunk_end > len(raw_bytes):
return
chunk_type = raw_bytes[cursor + 4 : cursor + 8]
chunk_bytes = raw_bytes[cursor:chunk_end]
if chunk_type in _VOLATILE_PNG_CHUNK_TYPES:
changed = True
else:
kept_chunks.append(chunk_bytes)
cursor = chunk_end
if chunk_type == b"IEND":
break
if not changed:
return
output_path.write_bytes(_PNG_SIGNATURE + b"".join(kept_chunks))
def _normalize_output_artifact(output_path: str) -> None:
_strip_volatile_png_metadata(Path(output_path))
def _materialize_public_output(line: OrderLine, output_path: str) -> str:
canonical_path = Path(_canonical_public_output_path(line, output_path))
source_path = Path(output_path)
canonical_path.parent.mkdir(parents=True, exist_ok=True)
ensure_group_writable_dir(canonical_path.parent)
if source_path != canonical_path:
shutil.copy2(source_path, canonical_path)
return str(canonical_path)
@@ -765,6 +1088,7 @@ def persist_order_line_media_asset(
resolved_workflow_run_id = _resolve_existing_workflow_run_id(session, workflow_run_id)
if success:
_normalize_output_artifact(output_path)
storage_key = _normalize_storage_key(output_path)
output_file = Path(output_path)
existing_asset = session.execute(
@@ -906,13 +1230,14 @@ def persist_order_line_output(
) -> OutputSaveResult:
"""Persist the render result for an order line and publish the media asset if needed."""
status: Literal["completed", "failed"] = "completed" if success else "failed"
completed_at = render_completed_at or datetime.utcnow()
completed_at = render_completed_at or _utcnow_naive()
persisted_output_path = output_path
line.render_status = status
line.render_completed_at = completed_at
line.render_log = render_log
if success:
_normalize_output_artifact(output_path)
persisted_output_path = _materialize_public_output(line, output_path)
line.result_path = persisted_output_path if success else None
session.flush()
@@ -1084,7 +1409,7 @@ def prepare_order_line_render_context(
reason="missing_cad_file",
)
render_start = datetime.utcnow() if persist_state else None
render_start = _utcnow_naive() if persist_state else None
if persist_state:
session.execute(
sql_update(OrderLine)
@@ -1111,7 +1436,12 @@ def prepare_order_line_render_context(
.limit(1)
).scalar_one_or_none()
if usd_asset:
refresh_reason = _usd_master_refresh_reason(cad_file)
usd_candidate_path = _resolve_asset_path(usd_asset.storage_key)
refresh_reason = _usd_master_refresh_reason(
cad_file,
usd_asset=usd_asset,
usd_render_path=usd_candidate_path,
)
if refresh_reason is not None:
logger.warning(
"render_order_line: ignoring stale usd_master for cad %s (%s)",
@@ -1127,7 +1457,7 @@ def prepare_order_line_render_context(
if _queue_usd_master_refresh(str(cad_file.id)):
_emit(emit, order_line_id, "Queued USD master regeneration in background")
else:
usd_render_path = _resolve_asset_path(usd_asset.storage_key)
usd_render_path = usd_candidate_path
if usd_render_path:
logger.info(
"render_order_line: using usd_master %s for cad %s",
@@ -1203,6 +1533,12 @@ def resolve_order_line_template_context(
material_library_path_override: str | None = None,
require_template: bool = False,
disable_materials: bool = False,
target_collection_override: str | None = None,
material_replace_mode: str | None = None,
lighting_only_mode: str | None = None,
shadow_catcher_mode: str | None = None,
camera_orbit_mode: str | None = None,
template_input_overrides: dict[str, Any] | None = None,
) -> TemplateResolutionResult:
"""Resolve render template, material library, and material map for a prepared order line."""
if not setup.is_ready:
@@ -1242,6 +1578,7 @@ def resolve_order_line_template_context(
if isinstance(material_library_path_override, str) and material_library_path_override.strip()
else get_material_library_path_for_session(session)
)
material_replace_override = _resolve_tristate_mode(material_replace_mode, field_name="material_replace_mode")
material_resolution = resolve_order_line_material_map(
line,
cad_file,
@@ -1250,8 +1587,36 @@ def resolve_order_line_template_context(
template=template,
emit=emit,
disable_materials=disable_materials,
material_replace_enabled_override=material_replace_override,
)
resolved_target_collection = (
target_collection_override.strip()
if isinstance(target_collection_override, str) and target_collection_override.strip()
else (
template.target_collection
if template is not None and template.target_collection
else "Product"
)
)
resolved_lighting_only = _resolve_tristate_mode(
lighting_only_mode,
field_name="lighting_only_mode",
fallback=bool(template.lighting_only) if template is not None else False,
)
resolved_shadow_catcher = _resolve_tristate_mode(
shadow_catcher_mode,
field_name="shadow_catcher_mode",
fallback=bool(template.shadow_catcher_enabled) if template is not None else False,
)
resolved_camera_orbit = _resolve_tristate_mode(
camera_orbit_mode,
field_name="camera_orbit_mode",
fallback=bool(template.camera_orbit) if template is not None else True,
)
workflow_input_schema = _normalize_template_input_schema(template)
template_inputs = _resolve_template_input_values(workflow_input_schema, template_input_overrides)
if template:
_emit(
emit,
@@ -1267,6 +1632,8 @@ def resolve_order_line_template_context(
template.blend_file_path,
template.lighting_only,
)
if template_inputs:
logger.info("Render template inputs resolved for '%s': %s", template.name, sorted(template_inputs))
if not template:
_emit(emit, str(line.id), "No render template found — using factory settings (Mode A)")
logger.info(
@@ -1281,8 +1648,14 @@ def resolve_order_line_template_context(
material_map=material_resolution.material_map,
use_materials=material_resolution.use_materials,
override_material=material_resolution.override_material,
target_collection=resolved_target_collection,
lighting_only=resolved_lighting_only,
shadow_catcher=resolved_shadow_catcher,
camera_orbit=resolved_camera_orbit,
category_key=category_key,
output_type_id=output_type_id,
workflow_input_schema=workflow_input_schema,
template_inputs=template_inputs,
)
@@ -1296,6 +1669,7 @@ def resolve_order_line_material_map(
emit: EmitFn = None,
material_override: str | None = None,
disable_materials: bool = False,
material_replace_enabled_override: bool | None = None,
) -> MaterialResolutionResult:
"""Resolve the effective order-line material map with legacy precedence rules."""
if disable_materials:
@@ -1311,11 +1685,15 @@ def resolve_order_line_material_map(
raw_material_count = 0
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:
if material_replace_enabled_override is not None:
use_materials = bool(material_replace_enabled_override and material_library and raw_material_map)
elif template and not template.material_replace_enabled:
use_materials = False
if use_materials:
raw_material_count = len(raw_material_map)
material_map = resolve_material_map(raw_material_map)
if cad_file:
material_map = _overlay_scene_manifest_material_map(cad_file, material_map)
line_override = getattr(line, "material_override", None)
output_override = line.output_type.material_override if line.output_type else None
@@ -1344,21 +1722,55 @@ def resolve_order_line_material_map(
)
def _overlay_scene_manifest_material_map(
cad_file: CadFile,
material_map: dict[str, str],
) -> dict[str, str]:
"""Overlay authoritative scene-manifest materials onto a resolved material map.
Low-level lookups still retain legacy/product source assignments so older
fallback paths keep working. The final order-line material map, however,
must prefer the scene manifest's effective assignments wherever the USD/CAD
pipeline has already established authoritative part identity.
"""
if not material_map:
return material_map
merged = dict(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 isinstance(effective_material, str) or not effective_material.strip():
continue
source_name = part.get("source_name")
part_key = part.get("part_key")
if isinstance(source_name, str) and source_name.strip():
merged[source_name] = effective_material
if isinstance(part_key, str) and part_key.strip():
merged[part_key] = effective_material
return merged
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.
Product/Excel CAD assignments stay authoritative for overlapping source-name
keys so legacy renders, thumbnails, and viewer previews keep parity with the
pre-USD pipeline. Scene-manifest assignments still fill gaps and emit part-key
aliases so USD and GLB/STEP fallback paths resolve the same effective 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")
}
authoritative_lookup = _build_authoritative_material_lookup(materials_source)
if not cad_file:
return raw_material_map
@@ -1372,10 +1784,16 @@ def _build_effective_material_lookup(
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)
authoritative_material = _resolve_authoritative_material_name(
str(source_name) if source_name else None,
authoritative_lookup,
str(part_key) if part_key else None,
)
merged_material = authoritative_material or str(effective_material)
if source_name and str(source_name) not in raw_material_map:
raw_material_map[str(source_name)] = merged_material
if part_key:
raw_material_map[str(part_key)] = str(effective_material)
raw_material_map.setdefault(str(part_key), merged_material)
return raw_material_map