feat: unify order-line render invocation paths
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -11,10 +13,17 @@ 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.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.models import GlobalRenderPosition, ProductRenderPosition, RenderTemplate
|
||||
from app.domains.rendering.output_type_contracts import merge_output_type_invocation_overrides
|
||||
from app.domains.rendering.models import (
|
||||
GlobalRenderPosition,
|
||||
ProductRenderPosition,
|
||||
RenderTemplate,
|
||||
WorkflowRun,
|
||||
)
|
||||
from app.services.material_service import resolve_material_map
|
||||
from app.services.step_processor import build_part_colors
|
||||
from app.services.template_service import (
|
||||
@@ -108,6 +117,216 @@ class OutputSaveResult:
|
||||
asset_type: MediaAssetType | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class OrderLineRenderInvocation:
|
||||
product_name: str
|
||||
output_type_name: str
|
||||
output_extension: str
|
||||
output_filename: str
|
||||
output_path: str
|
||||
is_animation: bool
|
||||
is_cinematic: bool
|
||||
width: int | None = None
|
||||
height: int | None = None
|
||||
engine: str | None = None
|
||||
samples: int | None = None
|
||||
frame_count: int = 24
|
||||
fps: int = 25
|
||||
bg_color: str = ""
|
||||
turntable_axis: str = "world_z"
|
||||
noise_threshold: str = ""
|
||||
denoiser: str = ""
|
||||
denoising_input_passes: str = ""
|
||||
denoising_prefilter: str = ""
|
||||
denoising_quality: str = ""
|
||||
denoising_use_gpu: str = ""
|
||||
transparent_bg: bool = False
|
||||
cycles_device: str = "auto"
|
||||
part_colors: dict[str, str] = field(default_factory=dict)
|
||||
part_names_ordered: list[str] | None = None
|
||||
template_path: str | None = None
|
||||
target_collection: str = "Product"
|
||||
material_library_path: str | None = None
|
||||
material_map: dict[str, str] | None = None
|
||||
lighting_only: bool = False
|
||||
shadow_catcher: bool = False
|
||||
camera_orbit: bool = True
|
||||
rotation_x: float = 0.0
|
||||
rotation_y: float = 0.0
|
||||
rotation_z: float = 0.0
|
||||
focal_length_mm: float | None = None
|
||||
sensor_width_mm: float | None = None
|
||||
usd_path: str | None = None
|
||||
material_override: str | None = None
|
||||
|
||||
def task_defaults(self) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"transparent_bg": self.transparent_bg,
|
||||
"cycles_device": self.cycles_device,
|
||||
"part_colors": self.part_colors,
|
||||
"target_collection": self.target_collection,
|
||||
"lighting_only": self.lighting_only,
|
||||
"shadow_catcher": self.shadow_catcher,
|
||||
"camera_orbit": self.camera_orbit,
|
||||
"rotation_x": self.rotation_x,
|
||||
"rotation_y": self.rotation_y,
|
||||
"rotation_z": self.rotation_z,
|
||||
"frame_count": self.frame_count,
|
||||
"fps": self.fps,
|
||||
"bg_color": self.bg_color,
|
||||
"turntable_axis": self.turntable_axis,
|
||||
"noise_threshold": self.noise_threshold,
|
||||
"denoiser": self.denoiser,
|
||||
"denoising_input_passes": self.denoising_input_passes,
|
||||
"denoising_prefilter": self.denoising_prefilter,
|
||||
"denoising_quality": self.denoising_quality,
|
||||
"denoising_use_gpu": self.denoising_use_gpu,
|
||||
}
|
||||
optional_values = {
|
||||
"width": self.width,
|
||||
"height": self.height,
|
||||
"engine": self.engine,
|
||||
"samples": self.samples,
|
||||
"template_path": self.template_path,
|
||||
"material_library_path": self.material_library_path,
|
||||
"material_map": self.material_map,
|
||||
"part_names_ordered": self.part_names_ordered,
|
||||
"focal_length_mm": self.focal_length_mm,
|
||||
"sensor_width_mm": self.sensor_width_mm,
|
||||
"usd_path": self.usd_path,
|
||||
"material_override": self.material_override,
|
||||
}
|
||||
for key, value in optional_values.items():
|
||||
if value not in (None, ""):
|
||||
payload[key] = value
|
||||
return payload
|
||||
|
||||
def as_still_renderer_kwargs(
|
||||
self,
|
||||
*,
|
||||
step_path: str,
|
||||
output_path: str,
|
||||
job_id: str | None = None,
|
||||
order_line_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"step_path": step_path,
|
||||
"output_path": output_path,
|
||||
"part_colors": self.part_colors or None,
|
||||
"width": self.width,
|
||||
"height": self.height,
|
||||
"transparent_bg": self.transparent_bg,
|
||||
"engine": self.engine,
|
||||
"samples": self.samples,
|
||||
"template_path": self.template_path,
|
||||
"target_collection": self.target_collection,
|
||||
"material_library_path": self.material_library_path,
|
||||
"material_map": self.material_map,
|
||||
"part_names_ordered": self.part_names_ordered,
|
||||
"lighting_only": self.lighting_only,
|
||||
"shadow_catcher": self.shadow_catcher,
|
||||
"cycles_device": self.cycles_device,
|
||||
"rotation_x": self.rotation_x,
|
||||
"rotation_y": self.rotation_y,
|
||||
"rotation_z": self.rotation_z,
|
||||
"job_id": job_id,
|
||||
"noise_threshold": self.noise_threshold,
|
||||
"denoiser": self.denoiser,
|
||||
"denoising_input_passes": self.denoising_input_passes,
|
||||
"denoising_prefilter": self.denoising_prefilter,
|
||||
"denoising_quality": self.denoising_quality,
|
||||
"denoising_use_gpu": self.denoising_use_gpu,
|
||||
"order_line_id": order_line_id,
|
||||
"usd_path": self.usd_path,
|
||||
"focal_length_mm": self.focal_length_mm,
|
||||
"sensor_width_mm": self.sensor_width_mm,
|
||||
"material_override": self.material_override,
|
||||
}
|
||||
|
||||
def as_turntable_renderer_kwargs(
|
||||
self,
|
||||
*,
|
||||
step_path: Path,
|
||||
output_path: Path,
|
||||
smooth_angle: int,
|
||||
default_width: int,
|
||||
default_height: int,
|
||||
default_engine: str,
|
||||
default_samples: int,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"step_path": step_path,
|
||||
"output_path": output_path,
|
||||
"frame_count": self.frame_count,
|
||||
"fps": self.fps,
|
||||
"width": self.width or default_width,
|
||||
"height": self.height or default_height,
|
||||
"engine": self.engine or default_engine,
|
||||
"samples": self.samples or default_samples,
|
||||
"smooth_angle": smooth_angle,
|
||||
"cycles_device": self.cycles_device,
|
||||
"transparent_bg": self.transparent_bg,
|
||||
"bg_color": self.bg_color,
|
||||
"turntable_axis": self.turntable_axis,
|
||||
"part_colors": self.part_colors or None,
|
||||
"template_path": self.template_path,
|
||||
"target_collection": self.target_collection,
|
||||
"material_library_path": self.material_library_path,
|
||||
"material_map": self.material_map,
|
||||
"part_names_ordered": self.part_names_ordered,
|
||||
"lighting_only": self.lighting_only,
|
||||
"shadow_catcher": self.shadow_catcher,
|
||||
"rotation_x": self.rotation_x,
|
||||
"rotation_y": self.rotation_y,
|
||||
"rotation_z": self.rotation_z,
|
||||
"camera_orbit": self.camera_orbit,
|
||||
"usd_path": self.usd_path,
|
||||
"focal_length_mm": self.focal_length_mm,
|
||||
"sensor_width_mm": self.sensor_width_mm,
|
||||
"material_override": self.material_override,
|
||||
}
|
||||
|
||||
def as_cinematic_renderer_kwargs(
|
||||
self,
|
||||
*,
|
||||
step_path: Path,
|
||||
output_path: Path,
|
||||
smooth_angle: int,
|
||||
default_width: int,
|
||||
default_height: int,
|
||||
default_engine: str,
|
||||
default_samples: int,
|
||||
log_callback: Callable[[str], None] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"step_path": step_path,
|
||||
"output_path": output_path,
|
||||
"width": self.width or default_width,
|
||||
"height": self.height or default_height,
|
||||
"engine": self.engine or default_engine,
|
||||
"samples": self.samples or default_samples,
|
||||
"smooth_angle": smooth_angle,
|
||||
"cycles_device": self.cycles_device,
|
||||
"transparent_bg": self.transparent_bg,
|
||||
"part_colors": self.part_colors or None,
|
||||
"template_path": self.template_path,
|
||||
"target_collection": self.target_collection,
|
||||
"material_library_path": self.material_library_path,
|
||||
"material_map": self.material_map,
|
||||
"part_names_ordered": self.part_names_ordered,
|
||||
"lighting_only": self.lighting_only,
|
||||
"shadow_catcher": self.shadow_catcher,
|
||||
"rotation_x": self.rotation_x,
|
||||
"rotation_y": self.rotation_y,
|
||||
"rotation_z": self.rotation_z,
|
||||
"usd_path": self.usd_path,
|
||||
"focal_length_mm": self.focal_length_mm,
|
||||
"sensor_width_mm": self.sensor_width_mm,
|
||||
"material_override": self.material_override,
|
||||
"log_callback": log_callback,
|
||||
}
|
||||
|
||||
|
||||
def _emit(emit: EmitFn, order_line_id: str, message: str, level: str | None = None) -> None:
|
||||
if emit is None:
|
||||
return
|
||||
@@ -118,14 +337,42 @@ def _emit(emit: EmitFn, order_line_id: str, message: str, level: str | None = No
|
||||
|
||||
|
||||
def _resolve_asset_path(storage_key: str | None) -> Path | None:
|
||||
if not storage_key:
|
||||
return None
|
||||
candidate = Path(app_settings.upload_dir) / storage_key
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
return resolve_result_path(storage_key)
|
||||
|
||||
|
||||
def _usd_master_refresh_reason(cad_file: CadFile) -> str | None:
|
||||
resolved = cad_file.resolved_material_assignments
|
||||
if not isinstance(resolved, dict) or not resolved:
|
||||
return "missing resolved material assignments"
|
||||
|
||||
canonical_materials: list[str] = []
|
||||
for meta in resolved.values():
|
||||
if not isinstance(meta, dict):
|
||||
continue
|
||||
canonical = meta.get("canonical_material")
|
||||
if isinstance(canonical, str) and canonical.strip():
|
||||
canonical_materials.append(canonical.strip())
|
||||
|
||||
if not canonical_materials:
|
||||
return "missing canonical material metadata"
|
||||
|
||||
if any(material.upper().startswith("SCHAEFFLER_") for material in canonical_materials):
|
||||
return "legacy Schaeffler material metadata"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _queue_usd_master_refresh(cad_file_id: str) -> bool:
|
||||
try:
|
||||
from app.tasks.step_tasks import generate_usd_master_task
|
||||
|
||||
generate_usd_master_task.delay(cad_file_id)
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception("render_order_line: failed to queue usd_master refresh for cad %s", cad_file_id)
|
||||
return False
|
||||
|
||||
|
||||
def extract_bbox_from_glb(glb_path: str) -> dict[str, dict[str, float]] | None:
|
||||
"""Extract a bounding box from a GLB file in meters and convert to mm."""
|
||||
try:
|
||||
@@ -207,8 +454,7 @@ def resolve_cad_bbox(
|
||||
|
||||
|
||||
def _normalize_storage_key(output_path: str) -> str:
|
||||
upload_prefix = str(app_settings.upload_dir).rstrip("/") + "/"
|
||||
return output_path[len(upload_prefix):] if output_path.startswith(upload_prefix) else output_path
|
||||
return result_path_to_storage_key(output_path) or output_path
|
||||
|
||||
|
||||
def _resolve_output_asset_type(output_path: str) -> MediaAssetType:
|
||||
@@ -218,6 +464,8 @@ def _resolve_output_asset_type(output_path: str) -> MediaAssetType:
|
||||
|
||||
def _resolve_output_mime_type(output_path: str) -> str:
|
||||
extension = output_path.rsplit(".", 1)[-1].lower() if "." in output_path else "bin"
|
||||
if extension == "blend":
|
||||
return "application/x-blender"
|
||||
if extension in ("mp4", "webm"):
|
||||
return "video/mp4"
|
||||
if extension == "webp":
|
||||
@@ -227,6 +475,333 @@ def _resolve_output_mime_type(output_path: str) -> str:
|
||||
return "image/png"
|
||||
|
||||
|
||||
def _sanitize_public_output_name(value: str) -> str:
|
||||
sanitized = re.sub(r"[^\w\-.]", "_", value.strip())
|
||||
return sanitized[:100] or "output"
|
||||
|
||||
|
||||
def _coerce_int(value: Any) -> int | None:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _coerce_bool(value: Any) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
return bool(value)
|
||||
|
||||
|
||||
def _resolve_render_output_extension(line: OrderLine) -> str:
|
||||
output_type = line.output_type
|
||||
output_extension = "jpg"
|
||||
if output_type is not None and output_type.output_format:
|
||||
fmt = str(output_type.output_format).lower()
|
||||
if fmt == "mp4":
|
||||
output_extension = "mp4"
|
||||
elif fmt == "webp":
|
||||
output_extension = "webp"
|
||||
elif fmt in {"png", "jpg", "jpeg"}:
|
||||
output_extension = "png" if fmt == "png" else "jpg"
|
||||
|
||||
render_overrides = getattr(line, "render_overrides", None)
|
||||
if isinstance(render_overrides, dict) and render_overrides.get("output_format") not in (None, ""):
|
||||
override = str(render_overrides["output_format"]).lower()
|
||||
if override == "mp4":
|
||||
return "mp4"
|
||||
if override == "webp":
|
||||
return "webp"
|
||||
if override in {"png", "jpg", "jpeg"}:
|
||||
return "png" if override == "png" else "jpg"
|
||||
return output_extension
|
||||
|
||||
|
||||
def _scale_render_samples_for_resolution(
|
||||
samples: int | None,
|
||||
width: int | None,
|
||||
height: int | None,
|
||||
) -> int | None:
|
||||
if samples is None or width is None or height is None:
|
||||
return samples
|
||||
max_dim = max(width, height)
|
||||
if max_dim > 1024:
|
||||
return samples
|
||||
scaled = max(32, int(samples * max_dim / 2048))
|
||||
return scaled if scaled < samples else samples
|
||||
|
||||
|
||||
def build_order_line_render_invocation(
|
||||
setup: OrderLineRenderSetupResult,
|
||||
*,
|
||||
template_context: TemplateResolutionResult | None = None,
|
||||
position_context: RenderPositionContext | None = None,
|
||||
material_context: MaterialResolutionResult | None = None,
|
||||
emit: EmitFn = None,
|
||||
) -> OrderLineRenderInvocation:
|
||||
if not setup.is_ready or setup.order_line is None or setup.cad_file is None:
|
||||
raise ValueError("build_order_line_render_invocation requires a ready order-line setup")
|
||||
|
||||
line = setup.order_line
|
||||
cad_file = setup.cad_file
|
||||
output_type = line.output_type
|
||||
position = position_context or RenderPositionContext()
|
||||
render_settings = (
|
||||
merge_output_type_invocation_overrides(
|
||||
output_type.render_settings,
|
||||
getattr(output_type, "invocation_overrides", None),
|
||||
)
|
||||
if output_type is not None
|
||||
else {}
|
||||
)
|
||||
|
||||
width = _coerce_int(render_settings.get("width"))
|
||||
height = _coerce_int(render_settings.get("height"))
|
||||
samples = _coerce_int(render_settings.get("samples"))
|
||||
frame_count = _coerce_int(render_settings.get("frame_count")) or 24
|
||||
fps = _coerce_int(render_settings.get("fps")) or 25
|
||||
engine = render_settings.get("engine")
|
||||
bg_color = str(render_settings.get("bg_color", ""))
|
||||
turntable_axis = str(render_settings.get("turntable_axis", "world_z"))
|
||||
noise_threshold = str(render_settings.get("noise_threshold", ""))
|
||||
denoiser = str(render_settings.get("denoiser", ""))
|
||||
denoising_input_passes = str(render_settings.get("denoising_input_passes", ""))
|
||||
denoising_prefilter = str(render_settings.get("denoising_prefilter", ""))
|
||||
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"
|
||||
|
||||
render_overrides = getattr(line, "render_overrides", None)
|
||||
if isinstance(render_overrides, dict):
|
||||
width = _coerce_int(render_overrides.get("width")) or width
|
||||
height = _coerce_int(render_overrides.get("height")) or height
|
||||
samples = _coerce_int(render_overrides.get("samples")) or samples
|
||||
frame_count = _coerce_int(render_overrides.get("frame_count")) or frame_count
|
||||
fps = _coerce_int(render_overrides.get("fps")) or fps
|
||||
engine = render_overrides.get("engine") or engine
|
||||
if render_overrides.get("bg_color") not in (None, ""):
|
||||
bg_color = str(render_overrides["bg_color"])
|
||||
if render_overrides.get("turntable_axis") not in (None, ""):
|
||||
turntable_axis = str(render_overrides["turntable_axis"])
|
||||
if render_overrides.get("noise_threshold") not in (None, ""):
|
||||
noise_threshold = str(render_overrides["noise_threshold"])
|
||||
if render_overrides.get("denoiser") not in (None, ""):
|
||||
denoiser = str(render_overrides["denoiser"])
|
||||
if render_overrides.get("denoising_input_passes") not in (None, ""):
|
||||
denoising_input_passes = str(render_overrides["denoising_input_passes"])
|
||||
if render_overrides.get("denoising_prefilter") not in (None, ""):
|
||||
denoising_prefilter = str(render_overrides["denoising_prefilter"])
|
||||
if render_overrides.get("denoising_quality") not in (None, ""):
|
||||
denoising_quality = str(render_overrides["denoising_quality"])
|
||||
if render_overrides.get("denoising_use_gpu") not in (None, ""):
|
||||
denoising_use_gpu = str(render_overrides["denoising_use_gpu"])
|
||||
if "transparent_bg" in render_overrides:
|
||||
transparent_bg = _coerce_bool(render_overrides["transparent_bg"])
|
||||
if render_overrides.get("cycles_device") not in (None, ""):
|
||||
cycles_device = str(render_overrides["cycles_device"])
|
||||
_emit(emit, str(line.id), f"Render overrides active: {render_overrides}")
|
||||
|
||||
scaled_samples = _scale_render_samples_for_resolution(samples, width, height)
|
||||
if (
|
||||
samples is not None
|
||||
and scaled_samples is not None
|
||||
and scaled_samples < samples
|
||||
and width is not None
|
||||
and height is not None
|
||||
):
|
||||
_emit(
|
||||
emit,
|
||||
str(line.id),
|
||||
f"Auto-scaled samples {samples} -> {scaled_samples} for {width}x{height}",
|
||||
)
|
||||
samples = scaled_samples
|
||||
|
||||
part_names_ordered = None
|
||||
if cad_file.parsed_objects:
|
||||
part_names = cad_file.parsed_objects.get("objects", [])
|
||||
part_names_ordered = part_names or None
|
||||
|
||||
product_name = line.product.name or line.product.pim_id or "product"
|
||||
output_type_name = output_type.name if output_type is not None else "render"
|
||||
output_extension = _resolve_render_output_extension(line)
|
||||
output_filename = (
|
||||
f"{_sanitize_public_output_name(product_name)}_"
|
||||
f"{_sanitize_public_output_name(output_type_name)}.{output_extension}"
|
||||
)
|
||||
output_dir = Path(app_settings.upload_dir) / "renders" / str(line.id)
|
||||
|
||||
material_map = None
|
||||
use_materials = False
|
||||
material_override = None
|
||||
if template_context is not None:
|
||||
material_map = template_context.material_map
|
||||
use_materials = template_context.use_materials
|
||||
material_override = template_context.override_material
|
||||
if material_context is not None:
|
||||
material_map = material_context.material_map
|
||||
use_materials = material_context.use_materials
|
||||
material_override = material_context.override_material
|
||||
|
||||
return OrderLineRenderInvocation(
|
||||
product_name=product_name,
|
||||
output_type_name=output_type_name,
|
||||
output_extension=output_extension,
|
||||
output_filename=output_filename,
|
||||
output_path=str(output_dir / output_filename),
|
||||
is_animation=bool(output_type and output_type.is_animation),
|
||||
is_cinematic=bool(output_type and render_settings.get("cinematic")),
|
||||
width=width,
|
||||
height=height,
|
||||
engine=str(engine) if engine not in (None, "") else None,
|
||||
samples=samples,
|
||||
frame_count=frame_count,
|
||||
fps=fps,
|
||||
bg_color=bg_color,
|
||||
turntable_axis=turntable_axis,
|
||||
noise_threshold=noise_threshold,
|
||||
denoiser=denoiser,
|
||||
denoising_input_passes=denoising_input_passes,
|
||||
denoising_prefilter=denoising_prefilter,
|
||||
denoising_quality=denoising_quality,
|
||||
denoising_use_gpu=denoising_use_gpu,
|
||||
transparent_bg=transparent_bg,
|
||||
cycles_device=cycles_device,
|
||||
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"
|
||||
),
|
||||
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,
|
||||
rotation_x=position.rotation_x,
|
||||
rotation_y=position.rotation_y,
|
||||
rotation_z=position.rotation_z,
|
||||
focal_length_mm=position.focal_length_mm,
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
def _canonical_public_output_path(line: OrderLine, output_path: str) -> str:
|
||||
source_path = Path(output_path)
|
||||
upload_root = Path(app_settings.upload_dir)
|
||||
|
||||
try:
|
||||
source_path.relative_to(upload_root / "renders")
|
||||
return str(source_path)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
extension = source_path.suffix or ".bin"
|
||||
product_name = None
|
||||
if line.product is not None:
|
||||
product_name = getattr(line.product, "name", None) or getattr(line.product, "pim_id", None)
|
||||
output_type_name = getattr(line.output_type, "name", None) if line.output_type is not None else None
|
||||
filename = f"{_sanitize_public_output_name(product_name or 'product')}_{_sanitize_public_output_name(output_type_name or 'render')}{extension}"
|
||||
return str(upload_root / "renders" / str(line.id) / filename)
|
||||
|
||||
|
||||
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)
|
||||
if source_path != canonical_path:
|
||||
shutil.copy2(source_path, canonical_path)
|
||||
return str(canonical_path)
|
||||
|
||||
|
||||
def _resolve_existing_workflow_run_id(session: Session, workflow_run_id: str | None) -> uuid.UUID | None:
|
||||
if workflow_run_id in (None, ""):
|
||||
return None
|
||||
try:
|
||||
candidate = uuid.UUID(str(workflow_run_id))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
existing = session.get(WorkflowRun, candidate)
|
||||
return existing.id if existing is not None else None
|
||||
|
||||
|
||||
def persist_order_line_media_asset(
|
||||
session: Session,
|
||||
line: OrderLine,
|
||||
*,
|
||||
success: bool,
|
||||
output_path: str,
|
||||
asset_type: MediaAssetType,
|
||||
render_log: dict[str, Any] | None = None,
|
||||
workflow_run_id: str | None = None,
|
||||
) -> OutputSaveResult:
|
||||
"""Persist a non-primary workflow artifact as a MediaAsset without mutating order-line result fields."""
|
||||
status: Literal["completed", "failed"] = "completed" if success else "failed"
|
||||
|
||||
asset_id: str | None = None
|
||||
storage_key: str | None = None
|
||||
resolved_workflow_run_id = _resolve_existing_workflow_run_id(session, workflow_run_id)
|
||||
|
||||
if success:
|
||||
storage_key = _normalize_storage_key(output_path)
|
||||
output_file = Path(output_path)
|
||||
existing_asset = session.execute(
|
||||
select(MediaAsset).where(MediaAsset.storage_key == storage_key).limit(1)
|
||||
).scalar_one_or_none()
|
||||
if existing_asset is None:
|
||||
asset = MediaAsset(
|
||||
tenant_id=line.product.cad_file.tenant_id if (line.product and line.product.cad_file) else None,
|
||||
product_id=line.product_id,
|
||||
cad_file_id=line.product.cad_file_id if line.product is not None else None,
|
||||
order_line_id=line.id,
|
||||
workflow_run_id=resolved_workflow_run_id,
|
||||
asset_type=asset_type,
|
||||
storage_key=storage_key,
|
||||
mime_type=_resolve_output_mime_type(output_path),
|
||||
file_size_bytes=output_file.stat().st_size if output_file.exists() else None,
|
||||
render_config=render_log if isinstance(render_log, dict) else None,
|
||||
)
|
||||
session.add(asset)
|
||||
session.flush()
|
||||
asset_id = str(asset.id)
|
||||
else:
|
||||
existing_asset.asset_type = asset_type
|
||||
existing_asset.order_line_id = line.id
|
||||
existing_asset.product_id = line.product_id
|
||||
existing_asset.cad_file_id = line.product.cad_file_id if line.product is not None else None
|
||||
existing_asset.mime_type = _resolve_output_mime_type(output_path)
|
||||
existing_asset.file_size_bytes = output_file.stat().st_size if output_file.exists() else None
|
||||
if isinstance(render_log, dict):
|
||||
existing_asset.render_config = render_log
|
||||
if resolved_workflow_run_id is not None:
|
||||
existing_asset.workflow_run_id = resolved_workflow_run_id
|
||||
session.flush()
|
||||
asset_id = str(existing_asset.id)
|
||||
|
||||
session.commit()
|
||||
return OutputSaveResult(
|
||||
status=status,
|
||||
result_path=output_path if success else None,
|
||||
asset_id=asset_id,
|
||||
storage_key=storage_key,
|
||||
asset_type=asset_type if success else None,
|
||||
)
|
||||
|
||||
|
||||
def _extract_render_error(render_log: dict[str, Any] | None) -> str | None:
|
||||
if not isinstance(render_log, dict):
|
||||
return None
|
||||
@@ -319,28 +894,43 @@ def persist_order_line_output(
|
||||
output_path: str,
|
||||
render_log: dict[str, Any] | None,
|
||||
render_completed_at: datetime | None = None,
|
||||
workflow_run_id: str | None = None,
|
||||
) -> 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()
|
||||
persisted_output_path = output_path
|
||||
|
||||
line.render_status = status
|
||||
line.render_completed_at = completed_at
|
||||
line.render_log = render_log
|
||||
line.result_path = output_path if success else None
|
||||
if success:
|
||||
persisted_output_path = _materialize_public_output(line, output_path)
|
||||
line.result_path = persisted_output_path if success else None
|
||||
session.flush()
|
||||
|
||||
asset_id: str | None = None
|
||||
storage_key: str | None = None
|
||||
asset_type: MediaAssetType | None = None
|
||||
resolved_workflow_run_id = _resolve_existing_workflow_run_id(session, workflow_run_id)
|
||||
if success:
|
||||
storage_key = _normalize_storage_key(output_path)
|
||||
asset_type = _resolve_output_asset_type(output_path)
|
||||
storage_key = _normalize_storage_key(persisted_output_path)
|
||||
asset_type = _resolve_output_asset_type(persisted_output_path)
|
||||
output_file = Path(persisted_output_path)
|
||||
existing_asset = session.execute(
|
||||
select(MediaAsset).where(MediaAsset.storage_key == storage_key).limit(1)
|
||||
).scalar_one_or_none()
|
||||
if existing_asset is None:
|
||||
output_file = Path(output_path)
|
||||
existing_asset = session.execute(
|
||||
select(MediaAsset)
|
||||
.where(
|
||||
MediaAsset.order_line_id == line.id,
|
||||
MediaAsset.asset_type == asset_type,
|
||||
)
|
||||
.order_by(MediaAsset.created_at.desc())
|
||||
.limit(1)
|
||||
).scalar_one_or_none()
|
||||
if existing_asset is None:
|
||||
render_config = None
|
||||
if isinstance(render_log, dict):
|
||||
render_config = {
|
||||
@@ -360,9 +950,10 @@ def persist_order_line_output(
|
||||
tenant_id=line.product.cad_file.tenant_id if (line.product and line.product.cad_file) else None,
|
||||
order_line_id=line.id,
|
||||
product_id=line.product_id,
|
||||
workflow_run_id=resolved_workflow_run_id,
|
||||
asset_type=asset_type,
|
||||
storage_key=storage_key,
|
||||
mime_type=_resolve_output_mime_type(output_path),
|
||||
mime_type=_resolve_output_mime_type(persisted_output_path),
|
||||
file_size_bytes=output_file.stat().st_size if output_file.exists() else None,
|
||||
width=None,
|
||||
height=None,
|
||||
@@ -372,9 +963,41 @@ def persist_order_line_output(
|
||||
session.flush()
|
||||
asset_id = str(asset.id)
|
||||
else:
|
||||
existing_asset.order_line_id = line.id
|
||||
existing_asset.product_id = line.product_id
|
||||
existing_asset.asset_type = asset_type
|
||||
existing_asset.storage_key = storage_key
|
||||
existing_asset.mime_type = _resolve_output_mime_type(persisted_output_path)
|
||||
existing_asset.file_size_bytes = output_file.stat().st_size if output_file.exists() else None
|
||||
if line.product is not None:
|
||||
existing_asset.cad_file_id = line.product.cad_file_id
|
||||
if isinstance(render_log, dict):
|
||||
existing_asset.render_config = {
|
||||
key: render_log[key]
|
||||
for key in (
|
||||
"renderer",
|
||||
"engine_used",
|
||||
"engine",
|
||||
"samples",
|
||||
"device_used",
|
||||
"compute_type",
|
||||
"total_duration_s",
|
||||
)
|
||||
if key in render_log
|
||||
}
|
||||
if resolved_workflow_run_id is not None:
|
||||
existing_asset.workflow_run_id = resolved_workflow_run_id
|
||||
session.flush()
|
||||
asset_id = str(existing_asset.id)
|
||||
|
||||
session.commit()
|
||||
if line.order_id is not None:
|
||||
try:
|
||||
from app.domains.orders.service import check_order_completion
|
||||
|
||||
check_order_completion(str(line.order_id))
|
||||
except Exception:
|
||||
logger.exception("Failed to check order completion for order_line %s", line.id)
|
||||
return OutputSaveResult(
|
||||
status=status,
|
||||
result_path=line.result_path,
|
||||
@@ -480,13 +1103,29 @@ def prepare_order_line_render_context(
|
||||
.limit(1)
|
||||
).scalar_one_or_none()
|
||||
if usd_asset:
|
||||
usd_render_path = _resolve_asset_path(usd_asset.storage_key)
|
||||
if usd_render_path:
|
||||
logger.info(
|
||||
"render_order_line: using usd_master %s for cad %s",
|
||||
usd_render_path.name,
|
||||
refresh_reason = _usd_master_refresh_reason(cad_file)
|
||||
if refresh_reason is not None:
|
||||
logger.warning(
|
||||
"render_order_line: ignoring stale usd_master for cad %s (%s)",
|
||||
cad_file.id,
|
||||
refresh_reason,
|
||||
)
|
||||
_emit(
|
||||
emit,
|
||||
order_line_id,
|
||||
f"Existing USD master is stale ({refresh_reason}) — falling back to GLB/STEP",
|
||||
"warning",
|
||||
)
|
||||
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)
|
||||
if usd_render_path:
|
||||
logger.info(
|
||||
"render_order_line: using usd_master %s for cad %s",
|
||||
usd_render_path.name,
|
||||
cad_file.id,
|
||||
)
|
||||
|
||||
glb_reuse_path = None
|
||||
if not usd_render_path:
|
||||
|
||||
Reference in New Issue
Block a user