feat: unify order-line render invocation paths

This commit is contained in:
2026-04-08 21:57:37 +02:00
parent 042f62fe55
commit dde04fcaa5
5 changed files with 3016 additions and 278 deletions
@@ -75,6 +75,7 @@ def render_order_line_task(self, order_line_id: str):
from sqlalchemy.orm import Session
from app.config import settings as app_settings
from app.domains.rendering.workflow_runtime_services import (
build_order_line_render_invocation,
emit_order_line_render_notifications,
persist_order_line_output,
prepare_order_line_render_context,
@@ -100,9 +101,6 @@ def render_order_line_task(self, order_line_id: str):
line = setup.order_line
cad_file = setup.cad_file
materials_source = setup.materials_source
usd_render_path = setup.usd_render_path
glb_reuse_path = setup.glb_reuse_path
part_colors = setup.part_colors
render_start = setup.render_start
@@ -112,10 +110,7 @@ def render_order_line_task(self, order_line_id: str):
emit=emit,
)
template = template_context.template
material_library = template_context.material_library
material_map = template_context.material_map
use_materials = template_context.use_materials
override_mat = template_context.override_material
cad_name = cad_file.original_name if cad_file else "?"
position_context = resolve_render_position_context(session, line, emit=emit)
@@ -125,150 +120,62 @@ def render_order_line_task(self, order_line_id: str):
focal_length_mm = position_context.focal_length_mm
sensor_width_mm = position_context.sensor_width_mm
emit(order_line_id, f"Starting render for {cad_name} ({len(part_colors)} coloured parts)")
# Determine if this is an animation output type
is_animation = bool(line.output_type and getattr(line.output_type, 'is_animation', False))
# Detect cinematic render type (render_settings.cinematic flag)
is_cinematic = bool(
line.output_type and
line.output_type.render_settings and
line.output_type.render_settings.get("cinematic")
render_invocation = build_order_line_render_invocation(
setup,
template_context=template_context,
position_context=position_context,
)
emit(order_line_id, f"Starting render for {cad_name} ({len(part_colors)} coloured parts)")
if getattr(line, "render_overrides", None):
emit(order_line_id, f"Render overrides active: {line.render_overrides}")
if (
line.output_type
and line.output_type.render_settings
and render_invocation.samples is not None
and line.output_type.render_settings.get("samples")
and render_invocation.width is not None
and render_invocation.height is not None
):
base_samples = int(line.output_type.render_settings["samples"])
if render_invocation.samples < base_samples:
emit(
order_line_id,
f"Auto-scaled samples {base_samples} -> {render_invocation.samples} "
f"for {render_invocation.width}x{render_invocation.height}",
)
# Determine output format/extension
out_ext = "jpg"
if line.output_type and line.output_type.output_format:
fmt = line.output_type.output_format.lower()
if fmt == "mp4":
out_ext = "mp4"
elif fmt == "webp":
out_ext = "webp"
elif fmt in ("png", "jpg", "jpeg"):
out_ext = "png" if fmt == "png" else "jpg"
# Build meaningful output filename
import re
def _sanitize(s: str) -> str:
return re.sub(r'[^\w\-.]', '_', s.strip())[:100]
product_name = line.product.name or line.product.pim_id or "product"
ot_name = line.output_type.name if line.output_type else "render"
filename = f"{_sanitize(product_name)}_{_sanitize(ot_name)}.{out_ext}"
# Render to per-line output directory
from pathlib import Path as _Path
render_dir = _Path(app_settings.upload_dir) / "renders" / order_line_id
render_dir.mkdir(parents=True, exist_ok=True)
output_path = str(render_dir / filename)
# Extract per-output-type render settings
render_width = None
render_height = None
render_engine = None
render_samples = None
noise_threshold = ""
denoiser = ""
denoising_input_passes = ""
denoising_prefilter = ""
denoising_quality = ""
denoising_use_gpu = ""
frame_count = 24
fps = 25
bg_color = ""
turntable_axis = "world_z"
if line.output_type and line.output_type.render_settings:
rs = line.output_type.render_settings
if rs.get("width"):
render_width = int(rs["width"])
if rs.get("height"):
render_height = int(rs["height"])
if rs.get("engine"):
render_engine = rs["engine"]
if rs.get("samples"):
render_samples = int(rs["samples"])
if rs.get("frame_count"):
frame_count = int(rs["frame_count"])
if rs.get("fps"):
fps = int(rs["fps"])
bg_color = rs.get("bg_color", "")
turntable_axis = rs.get("turntable_axis", "world_z")
noise_threshold = str(rs.get("noise_threshold", ""))
denoiser = str(rs.get("denoiser", ""))
denoising_input_passes = str(rs.get("denoising_input_passes", ""))
denoising_prefilter = str(rs.get("denoising_prefilter", ""))
denoising_quality = str(rs.get("denoising_quality", ""))
denoising_use_gpu = str(rs.get("denoising_use_gpu", ""))
# Auto-scale samples for lower resolutions (thumbnails/previews).
# Only applies when the output type provides both samples and dimensions.
if render_samples and render_width and render_height:
max_dim = max(render_width, render_height)
if max_dim <= 1024:
scaled = max(32, int(render_samples * max_dim / 2048))
if scaled < render_samples:
emit(order_line_id, f"Auto-scaled samples {render_samples} \u2192 {scaled} for {render_width}x{render_height}")
render_samples = scaled
transparent_bg = bool(line.output_type and line.output_type.transparent_bg)
cycles_device_val = (line.output_type.cycles_device or "auto") if line.output_type else "auto"
# Apply per-line render overrides (format, resolution, samples, etc.)
_render_overrides = getattr(line, 'render_overrides', None)
if _render_overrides and isinstance(_render_overrides, dict):
if 'width' in _render_overrides:
render_width = int(_render_overrides['width'])
if 'height' in _render_overrides:
render_height = int(_render_overrides['height'])
if 'samples' in _render_overrides:
render_samples = int(_render_overrides['samples'])
if 'engine' in _render_overrides:
render_engine = _render_overrides['engine']
if 'frame_count' in _render_overrides:
frame_count = int(_render_overrides['frame_count'])
if 'fps' in _render_overrides:
fps = int(_render_overrides['fps'])
if 'bg_color' in _render_overrides:
bg_color = _render_overrides['bg_color']
if 'turntable_axis' in _render_overrides:
turntable_axis = _render_overrides['turntable_axis']
if 'noise_threshold' in _render_overrides:
noise_threshold = str(_render_overrides['noise_threshold'])
if 'denoiser' in _render_overrides:
denoiser = str(_render_overrides['denoiser'])
if 'denoising_input_passes' in _render_overrides:
denoising_input_passes = str(_render_overrides['denoising_input_passes'])
if 'denoising_prefilter' in _render_overrides:
denoising_prefilter = str(_render_overrides['denoising_prefilter'])
if 'denoising_quality' in _render_overrides:
denoising_quality = str(_render_overrides['denoising_quality'])
if 'denoising_use_gpu' in _render_overrides:
denoising_use_gpu = str(_render_overrides['denoising_use_gpu'])
if 'transparent_bg' in _render_overrides:
transparent_bg = bool(_render_overrides['transparent_bg'])
if 'cycles_device' in _render_overrides:
cycles_device_val = _render_overrides['cycles_device']
emit(order_line_id, f"Render overrides active: {_render_overrides}")
# Apply output_format override (affects out_ext and filename)
if 'output_format' in _render_overrides:
fmt_override = _render_overrides['output_format'].lower()
if fmt_override == "mp4":
out_ext = "mp4"
elif fmt_override == "webp":
out_ext = "webp"
elif fmt_override in ("png", "jpg", "jpeg"):
out_ext = "png" if fmt_override == "png" else "jpg"
# Rebuild filename with new extension
filename = f"{_sanitize(product_name)}_{_sanitize(ot_name)}.{out_ext}"
output_path = str(render_dir / filename)
# Build ordered part names list for index-based Blender matching
part_names_ordered = None
if cad_file and cad_file.parsed_objects:
part_names_ordered = cad_file.parsed_objects.get("objects", []) or None
is_animation = render_invocation.is_animation
is_cinematic = render_invocation.is_cinematic
product_name = render_invocation.product_name
ot_name = render_invocation.output_type_name
output_path = render_invocation.output_path
_Path(output_path).parent.mkdir(parents=True, exist_ok=True)
render_width = render_invocation.width
render_height = render_invocation.height
render_engine = render_invocation.engine
render_samples = render_invocation.samples
frame_count = render_invocation.frame_count
fps = render_invocation.fps
bg_color = render_invocation.bg_color
turntable_axis = render_invocation.turntable_axis
noise_threshold = render_invocation.noise_threshold
denoiser = render_invocation.denoiser
denoising_input_passes = render_invocation.denoising_input_passes
denoising_prefilter = render_invocation.denoising_prefilter
denoising_quality = render_invocation.denoising_quality
denoising_use_gpu = render_invocation.denoising_use_gpu
transparent_bg = render_invocation.transparent_bg
cycles_device_val = render_invocation.cycles_device
part_names_ordered = render_invocation.part_names_ordered
material_override = render_invocation.material_override
target_collection = render_invocation.target_collection
template_path = render_invocation.template_path
material_library_path = render_invocation.material_library_path
camera_orbit = render_invocation.camera_orbit
lighting_only = render_invocation.lighting_only
shadow_catcher = render_invocation.shadow_catcher
usd_path = render_invocation.usd_path
step_path = _Path(cad_file.stored_path)
tmpl_info = f" template={template.name}" if template else ""
if is_cinematic:
@@ -285,33 +192,22 @@ def render_order_line_task(self, order_line_id: str):
from app.services.step_processor import _get_all_settings
_sys = _get_all_settings()
try:
service_data = render_cinematic_to_file(
step_path=_Path(cad_file.stored_path),
cinematic_kwargs = render_invocation.as_cinematic_renderer_kwargs(
step_path=step_path,
output_path=_Path(output_path),
width=render_width or 1920,
height=render_height or 1080,
engine=render_engine or _sys.get("blender_engine", "cycles"),
samples=render_samples or int(_sys.get(f"blender_{render_engine or _sys.get('blender_engine','cycles')}_samples", 128)),
default_width=1920,
default_height=1080,
default_engine=_sys.get("blender_engine", "cycles"),
default_samples=int(
_sys.get(
f"blender_{render_engine or _sys.get('blender_engine', 'cycles')}_samples",
128,
)
),
smooth_angle=int(_sys.get("blender_smooth_angle", 30)),
cycles_device=cycles_device_val,
transparent_bg=transparent_bg,
part_colors=part_colors or None,
template_path=template.blend_file_path if template else None,
target_collection=template.target_collection if template else "Product",
material_library_path=material_library if use_materials else None,
material_map=material_map,
part_names_ordered=part_names_ordered,
lighting_only=bool(template.lighting_only) if template else False,
shadow_catcher=bool(template.shadow_catcher_enabled) if template else False,
rotation_x=rotation_x,
rotation_y=rotation_y,
rotation_z=rotation_z,
usd_path=usd_render_path,
focal_length_mm=focal_length_mm,
sensor_width_mm=sensor_width_mm,
material_override=override_mat,
log_callback=lambda line: emit(order_line_id, line),
)
service_data = render_cinematic_to_file(**cinematic_kwargs)
success = True
render_log = {
"renderer": "blender",
@@ -352,37 +248,21 @@ def render_order_line_task(self, order_line_id: str):
from app.services.step_processor import _get_all_settings
_sys = _get_all_settings()
try:
service_data = render_turntable_to_file(
step_path=_Path(cad_file.stored_path),
turntable_kwargs = render_invocation.as_turntable_renderer_kwargs(
step_path=step_path,
output_path=_Path(output_path),
frame_count=frame_count,
fps=fps,
width=render_width or 1920,
height=render_height or 1920,
engine=render_engine or _sys.get("blender_engine", "cycles"),
samples=render_samples or int(_sys.get(f"blender_{render_engine or _sys.get('blender_engine','cycles')}_samples", 128)),
default_width=1920,
default_height=1920,
default_engine=_sys.get("blender_engine", "cycles"),
default_samples=int(
_sys.get(
f"blender_{render_engine or _sys.get('blender_engine', 'cycles')}_samples",
128,
)
),
smooth_angle=int(_sys.get("blender_smooth_angle", 30)),
cycles_device=cycles_device_val,
transparent_bg=transparent_bg,
bg_color=bg_color,
turntable_axis=turntable_axis,
part_colors=part_colors or None,
template_path=template.blend_file_path if template else None,
target_collection=template.target_collection if template else "Product",
material_library_path=material_library if use_materials else None,
material_map=material_map,
part_names_ordered=part_names_ordered,
lighting_only=bool(template.lighting_only) if template else False,
shadow_catcher=bool(template.shadow_catcher_enabled) if template else False,
rotation_x=rotation_x,
rotation_y=rotation_y,
rotation_z=rotation_z,
camera_orbit=bool(template.camera_orbit) if template else True,
usd_path=usd_render_path,
focal_length_mm=focal_length_mm,
sensor_width_mm=sensor_width_mm,
material_override=override_mat,
)
service_data = render_turntable_to_file(**turntable_kwargs)
success = True
render_log = {
"renderer": "blender",
@@ -414,43 +294,18 @@ def render_order_line_task(self, order_line_id: str):
logger.error("Turntable render failed for %s: %s", order_line_id, exc)
else:
# ── Still image path ────────────────────────────────────────
_render_path_label = "USD → Blender" if usd_render_path else "STEP → GLB → Blender"
_render_path_label = "USD → Blender" if usd_path else "STEP → GLB → Blender"
emit(order_line_id, f"Calling renderer ({_render_path_label}) {render_width or 'default'}x{render_height or 'default'}{' [transparent]' if transparent_bg else ''}{f' engine={render_engine}' if render_engine else ''}{f' samples={render_samples}' if render_samples else ''}{tmpl_info}")
pl.step_start("blender_still", {"width": render_width, "height": render_height})
from app.services.step_processor import render_to_file
success, render_log = render_to_file(
step_path=cad_file.stored_path,
output_path=output_path,
part_colors=part_colors,
width=render_width,
height=render_height,
transparent_bg=transparent_bg,
engine=render_engine,
samples=render_samples,
template_path=template.blend_file_path if template else None,
target_collection=template.target_collection if template else "Product",
material_library_path=material_library if use_materials else None,
material_map=material_map,
part_names_ordered=part_names_ordered,
lighting_only=bool(template.lighting_only) if template else False,
shadow_catcher=bool(template.shadow_catcher_enabled) if template else False,
cycles_device=line.output_type.cycles_device if line.output_type else None,
rotation_x=rotation_x,
rotation_y=rotation_y,
rotation_z=rotation_z,
focal_length_mm=focal_length_mm,
sensor_width_mm=sensor_width_mm,
material_override=override_mat,
job_id=order_line_id,
order_line_id=order_line_id,
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,
usd_path=usd_render_path,
**render_invocation.as_still_renderer_kwargs(
step_path=cad_file.stored_path,
output_path=output_path,
job_id=order_line_id,
order_line_id=order_line_id,
)
)
if success:
pl.step_done("blender_still")
@@ -2,6 +2,7 @@ from __future__ import annotations
import logging
import time
import uuid
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
@@ -10,7 +11,9 @@ from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.config import settings
from app.core.process_steps import StepName
from app.domains.products.models import CadFile
from app.domains.rendering.models import WorkflowNodeResult, WorkflowRun
from app.domains.rendering.workflow_executor import STEP_TASK_MAP, WorkflowContext, WorkflowDispatchResult
from app.domains.rendering.workflow_node_registry import get_node_definition
@@ -21,10 +24,12 @@ from app.domains.rendering.workflow_runtime_services import (
OrderLineRenderSetupResult,
TemplateResolutionResult,
auto_populate_materials_for_cad,
build_order_line_render_invocation,
prepare_order_line_render_context,
resolve_cad_bbox,
resolve_order_line_material_map,
resolve_order_line_template_context,
resolve_render_position_context,
)
logger = logging.getLogger(__name__)
@@ -37,6 +42,7 @@ class WorkflowGraphRuntimeError(RuntimeError):
@dataclass(slots=True)
class WorkflowGraphState:
setup: OrderLineRenderSetupResult | None = None
cad_file: CadFile | None = None
template: TemplateResolutionResult | None = None
materials: MaterialResolutionResult | None = None
auto_populate: AutoPopulateMaterialsResult | None = None
@@ -52,6 +58,119 @@ _ORDER_LINE_RENDER_STEPS = {
StepName.NOTIFY,
}
_STILL_TASK_KEYS = {
"width",
"height",
"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",
"noise_threshold",
"denoiser",
"denoising_input_passes",
"denoising_prefilter",
"denoising_quality",
"denoising_use_gpu",
"usd_path",
"focal_length_mm",
"sensor_width_mm",
"material_override",
"render_engine",
"resolution",
}
_TURNTABLE_TASK_KEYS = {
"output_name",
"engine",
"samples",
"smooth_angle",
"cycles_device",
"transparent_bg",
"width",
"height",
"frame_count",
"fps",
"turntable_degrees",
"turntable_axis",
"bg_color",
"template_path",
"target_collection",
"material_library_path",
"material_map",
"part_names_ordered",
"lighting_only",
"shadow_catcher",
"camera_orbit",
"rotation_x",
"rotation_y",
"rotation_z",
"focal_length_mm",
"sensor_width_mm",
"material_override",
}
_THUMBNAIL_TASK_KEYS = {
"renderer",
"render_engine",
"samples",
"width",
"height",
"transparent_bg",
}
_AUTHORITATIVE_RENDER_SETTING_KEYS = {
"render_engine",
"engine",
"samples",
"width",
"height",
"transparent_bg",
"cycles_device",
"noise_threshold",
"denoiser",
"denoising_input_passes",
"denoising_prefilter",
"denoising_quality",
"denoising_use_gpu",
"camera_orbit",
"focal_length_mm",
"sensor_width_mm",
"bg_color",
}
def _filter_graph_render_overrides(step: StepName, params: dict[str, Any]) -> dict[str, Any]:
normalized = dict(params)
use_custom_render_settings = bool(normalized.pop("use_custom_render_settings", False))
if use_custom_render_settings:
return normalized
filtered = dict(normalized)
for key in _AUTHORITATIVE_RENDER_SETTING_KEYS:
if key in filtered:
filtered.pop(key, None)
if step == StepName.BLENDER_TURNTABLE:
# Turntable timing remains workflow-specific even when render quality inherits from the output type.
for key in ("fps", "duration_s", "frame_count", "turntable_degrees", "turntable_axis"):
value = normalized.get(key)
if value not in (None, ""):
filtered[key] = value
return filtered
def find_unsupported_graph_nodes(workflow_context: WorkflowContext) -> list[str]:
unsupported: list[str] = []
@@ -119,6 +238,7 @@ def execute_graph_workflow(
session=session,
workflow_context=workflow_context,
state=state,
node=node,
node_params=node.params,
)
except Exception as exc:
@@ -208,14 +328,12 @@ def execute_graph_workflow(
from app.tasks.celery_app import celery_app
task_kwargs = dict(node.params)
task_kwargs["workflow_run_id"] = str(workflow_context.workflow_run_id)
task_kwargs["workflow_node_id"] = node.id
if workflow_context.execution_mode == "shadow":
task_kwargs["publish_asset_enabled"] = False
task_kwargs["emit_events"] = False
task_kwargs["job_document_enabled"] = False
task_kwargs["output_name_suffix"] = f"shadow-{str(workflow_context.workflow_run_id)[:8]}"
task_kwargs = _build_task_kwargs(
session=session,
workflow_context=workflow_context,
state=state,
node=node,
)
result = celery_app.send_task(
task_name,
@@ -228,10 +346,19 @@ def execute_graph_workflow(
metadata["attempt_count"] = 1
metadata["max_attempts"] = retry_policy["max_attempts"]
metadata["execution_mode"] = workflow_context.execution_mode
predicted_output = _predict_task_output_metadata(
workflow_context=workflow_context,
state=state,
node=node,
task_kwargs=task_kwargs,
)
if predicted_output:
metadata.update(predicted_output)
node_result.status = "queued"
node_result.output = metadata
node_result.log = None
node_result.duration_s = None
state.node_outputs[node.id] = dict(metadata)
session.flush()
task_ids.append(result.id)
node_task_ids[node.id] = result.id
@@ -377,13 +504,330 @@ def _serialize_bbox_result(result: BBoxResolutionResult) -> dict[str, Any]:
}
def _serialize_cad_file_result(cad_file: CadFile) -> dict[str, Any]:
parsed_objects = cad_file.parsed_objects or {}
objects = parsed_objects.get("objects")
object_count = len(objects) if isinstance(objects, list) else None
return {
"cad_file_id": str(cad_file.id),
"step_path": cad_file.stored_path,
"original_name": cad_file.original_name,
"processing_status": cad_file.processing_status.value if getattr(cad_file, "processing_status", None) else None,
"object_count": object_count,
"has_parsed_objects": bool(parsed_objects),
"gltf_path": cad_file.gltf_path,
}
def _workflow_node_ids(workflow_context: WorkflowContext, step: StepName) -> list[str]:
return [node.id for node in workflow_context.ordered_nodes if node.step == step]
def _workflow_node_map(workflow_context: WorkflowContext) -> dict[str, Any]:
return {node.id: node for node in workflow_context.ordered_nodes}
def _upstream_node_ids(workflow_context: WorkflowContext, node_id: str) -> list[str]:
return [edge.from_node for edge in workflow_context.edges if edge.to_node == node_id]
def _downstream_node_ids(workflow_context: WorkflowContext, node_id: str) -> list[str]:
return [edge.to_node for edge in workflow_context.edges if edge.from_node == node_id]
def _connected_node_ids_by_step(
workflow_context: WorkflowContext,
*,
node_id: str,
step: StepName,
direction: str,
) -> list[str]:
node_map = _workflow_node_map(workflow_context)
if direction == "upstream":
candidate_ids = _upstream_node_ids(workflow_context, node_id)
elif direction == "downstream":
candidate_ids = _downstream_node_ids(workflow_context, node_id)
else:
raise ValueError(f"Unsupported graph direction: {direction}")
return [
candidate_id
for candidate_id in candidate_ids
if node_map.get(candidate_id) is not None and node_map[candidate_id].step == step
]
def _connected_upstream_artifacts(
workflow_context: WorkflowContext,
state: WorkflowGraphState,
node_id: str,
) -> list[dict[str, Any]]:
preferred_upstream_ids = set(_upstream_node_ids(workflow_context, node_id))
artifacts = _collect_upstream_artifacts(state)
if not preferred_upstream_ids:
return []
return [artifact for artifact in artifacts if artifact["node_id"] in preferred_upstream_ids]
def _predict_task_output_metadata(
*,
workflow_context: WorkflowContext,
state: WorkflowGraphState,
node,
task_kwargs: dict[str, Any],
) -> dict[str, Any]:
if node.step == StepName.THUMBNAIL_SAVE:
renderer = str(task_kwargs.get("renderer") or "blender")
output_format = "png" if renderer == "threejs" or bool(task_kwargs.get("transparent_bg")) else "jpg"
output_dir = Path(settings.upload_dir) / "thumbnails"
return {
"artifact_role": "thumbnail_output",
"predicted_output_path": str(output_dir / f"{workflow_context.context_id}.{output_format}"),
"predicted_asset_type": "thumbnail",
"publish_asset_enabled": True,
"graph_authoritative_output_enabled": True,
"graph_output_node_ids": [node.id],
"notify_handoff_enabled": False,
}
if state.setup is None or state.setup.order_line is None or state.setup.cad_file is None:
return {}
step_path = Path(state.setup.cad_file.stored_path)
output_name_suffix = task_kwargs.get("output_name_suffix")
order_line_id = str(state.setup.order_line.id)
if node.step == StepName.BLENDER_STILL:
output_dir = step_path.parent / "renders"
output_filename = f"line_{order_line_id}.png"
if output_name_suffix:
output_filename = f"line_{order_line_id}_{output_name_suffix}.png"
return {
"artifact_role": "render_output",
"predicted_output_path": str(output_dir / output_filename),
"predicted_asset_type": "still",
"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 []),
}
if node.step == StepName.EXPORT_BLEND:
output_filename = f"{step_path.stem}_production.blend"
if output_name_suffix:
output_filename = f"{step_path.stem}_production_{output_name_suffix}.blend"
return {
"artifact_role": "blend_export",
"predicted_output_path": str(step_path.parent / output_filename),
"predicted_asset_type": "blend_production",
"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 []),
}
if node.step == StepName.BLENDER_TURNTABLE:
output_name = str(task_kwargs.get("output_name") or "turntable")
output_name_suffix = task_kwargs.get("output_name_suffix")
if output_name_suffix:
output_name = f"{output_name}_{output_name_suffix}"
output_dir = task_kwargs.get("output_dir")
predicted_output_path = None
if isinstance(output_dir, str) and output_dir.strip():
predicted_output_path = str(Path(output_dir) / f"{output_name}.mp4")
else:
predicted_output_path = str(step_path.parent / "renders" / f"{output_name}.mp4")
return {
"artifact_role": "turntable_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 {}
def _collect_upstream_artifacts(state: WorkflowGraphState) -> list[dict[str, Any]]:
artifacts: list[dict[str, Any]] = []
for node_id, output in state.node_outputs.items():
predicted_output_path = output.get("predicted_output_path")
artifact_role = output.get("artifact_role")
if not artifact_role and not predicted_output_path:
continue
artifacts.append(
{
"node_id": node_id,
"artifact_role": artifact_role,
"predicted_output_path": predicted_output_path,
"predicted_asset_type": output.get("predicted_asset_type"),
"publish_asset_enabled": bool(output.get("publish_asset_enabled", False)),
"graph_authoritative_output_enabled": bool(
output.get("graph_authoritative_output_enabled", False)
),
"graph_output_node_ids": list(output.get("graph_output_node_ids") or []),
"notify_handoff_enabled": bool(output.get("notify_handoff_enabled", False)),
"task_id": output.get("task_id"),
**(
{"graph_notify_node_ids": list(output.get("graph_notify_node_ids") or [])}
if output.get("graph_notify_node_ids")
else {}
),
}
)
return artifacts
def _resolve_cad_file_context(
session: Session,
workflow_context: WorkflowContext,
state: WorkflowGraphState,
) -> CadFile:
if state.cad_file is not None:
return state.cad_file
try:
cad_file_id = workflow_context.context_id
except AttributeError as exc:
raise WorkflowGraphRuntimeError("cad_file context_id is missing") from exc
try:
parsed_cad_file_id = uuid.UUID(cad_file_id)
except ValueError as exc:
raise WorkflowGraphRuntimeError(f"cad_file context is not a valid UUID: {cad_file_id}") from exc
cad_file = session.get(CadFile, parsed_cad_file_id)
if cad_file is None:
raise WorkflowGraphRuntimeError(f"cad_file context not found: {cad_file_id}")
state.cad_file = cad_file
return cad_file
def _resolve_thumbnail_request(
workflow_context: WorkflowContext,
state: WorkflowGraphState,
node_id: str,
) -> dict[str, Any] | None:
preferred_upstream_ids = set(_upstream_node_ids(workflow_context, node_id))
if preferred_upstream_ids:
for upstream_node in reversed(workflow_context.ordered_nodes):
if upstream_node.id not in preferred_upstream_ids:
continue
output = state.node_outputs.get(upstream_node.id)
if output and output.get("thumbnail_request") is True:
return output
for output in reversed(list(state.node_outputs.values())):
if output.get("thumbnail_request") is True:
return output
return None
def _build_task_kwargs(
*,
session: Session,
workflow_context: WorkflowContext,
state: WorkflowGraphState,
node,
) -> dict[str, Any]:
task_kwargs = dict(node.params)
connected_output_node_ids: list[str] = []
connected_notify_node_ids: list[str] = []
render_defaults: dict[str, Any] = {}
if state.setup is not None and state.setup.is_ready and state.setup.order_line is not None:
render_invocation = build_order_line_render_invocation(
state.setup,
template_context=state.template,
position_context=resolve_render_position_context(session, state.setup.order_line),
material_context=state.materials,
)
render_defaults = render_invocation.task_defaults()
if node.step == StepName.BLENDER_STILL:
task_kwargs = _filter_graph_render_overrides(StepName.BLENDER_STILL, task_kwargs)
task_kwargs = {
key: value
for key, value in {
**render_defaults,
**task_kwargs,
}.items()
if key in _STILL_TASK_KEYS
}
elif node.step == StepName.BLENDER_TURNTABLE:
task_kwargs = _filter_graph_render_overrides(StepName.BLENDER_TURNTABLE, task_kwargs)
task_kwargs = {
key: value
for key, value in {
**render_defaults,
**task_kwargs,
}.items()
if key in _TURNTABLE_TASK_KEYS
}
elif node.step == StepName.THUMBNAIL_SAVE:
thumbnail_request = _resolve_thumbnail_request(workflow_context, state, node.id) or {}
task_kwargs = {
key: value
for key, value in {
**thumbnail_request,
**task_kwargs,
}.items()
if key in _THUMBNAIL_TASK_KEYS
}
task_kwargs["workflow_run_id"] = str(workflow_context.workflow_run_id)
task_kwargs["workflow_node_id"] = node.id
if workflow_context.execution_mode == "graph" and node.step in {
StepName.BLENDER_STILL,
StepName.EXPORT_BLEND,
StepName.BLENDER_TURNTABLE,
}:
connected_output_node_ids = _connected_node_ids_by_step(
workflow_context,
node_id=node.id,
step=StepName.OUTPUT_SAVE,
direction="downstream",
)
connected_notify_node_ids = _connected_node_ids_by_step(
workflow_context,
node_id=node.id,
step=StepName.NOTIFY,
direction="downstream",
)
if connected_output_node_ids:
task_kwargs["publish_asset_enabled"] = False
task_kwargs["graph_authoritative_output_enabled"] = True
task_kwargs["graph_output_node_ids"] = connected_output_node_ids
if connected_notify_node_ids:
task_kwargs["emit_legacy_notifications"] = True
task_kwargs["graph_notify_node_ids"] = connected_notify_node_ids
if workflow_context.execution_mode == "shadow":
task_kwargs["publish_asset_enabled"] = False
task_kwargs["emit_events"] = False
task_kwargs["job_document_enabled"] = False
task_kwargs["output_name_suffix"] = f"shadow-{str(workflow_context.workflow_run_id)[:8]}"
return task_kwargs
def _execute_order_line_setup(
*,
session: Session,
workflow_context: WorkflowContext,
state: WorkflowGraphState,
node,
node_params: dict[str, Any],
) -> tuple[dict[str, Any], str, str | None]:
del node
del node_params
shadow_mode = workflow_context.execution_mode == "shadow"
if shadow_mode:
@@ -409,8 +853,10 @@ def _execute_resolve_template(
session: Session,
workflow_context: WorkflowContext,
state: WorkflowGraphState,
node,
node_params: dict[str, Any],
) -> tuple[dict[str, Any], str, str | None]:
del node
del workflow_context, node_params
if state.setup is None or not state.setup.is_ready:
if state.setup is not None and state.setup.status == "skip":
@@ -426,8 +872,10 @@ def _execute_material_map_resolve(
session: Session,
workflow_context: WorkflowContext,
state: WorkflowGraphState,
node,
node_params: dict[str, Any],
) -> tuple[dict[str, Any], str, str | None]:
del node
del session, workflow_context, node_params
if state.setup is None or not state.setup.is_ready:
if state.setup is not None and state.setup.status == "skip":
@@ -457,8 +905,10 @@ def _execute_auto_populate_materials(
session: Session,
workflow_context: WorkflowContext,
state: WorkflowGraphState,
node,
node_params: dict[str, Any],
) -> tuple[dict[str, Any], str, str | None]:
del node
del node_params
if state.setup is None or state.setup.cad_file is None:
if state.setup is not None and state.setup.status == "skip":
@@ -487,8 +937,10 @@ def _execute_glb_bbox(
session: Session,
workflow_context: WorkflowContext,
state: WorkflowGraphState,
node,
node_params: dict[str, Any],
) -> tuple[dict[str, Any], str, str | None]:
del node
del session, workflow_context
if state.setup is None or state.setup.cad_file is None:
if state.setup is not None and state.setup.status == "skip":
@@ -510,10 +962,198 @@ def _execute_glb_bbox(
return _serialize_bbox_result(result), "completed", None
def _execute_resolve_step_path(
*,
session: Session,
workflow_context: WorkflowContext,
state: WorkflowGraphState,
node,
node_params: dict[str, Any],
) -> tuple[dict[str, Any], str, str | None]:
del node
del node_params
cad_file = _resolve_cad_file_context(session, workflow_context, state)
return _serialize_cad_file_result(cad_file), "completed", None
def _execute_stl_cache_generate(
*,
session: Session,
workflow_context: WorkflowContext,
state: WorkflowGraphState,
node,
node_params: dict[str, Any],
) -> tuple[dict[str, Any], str, str | None]:
del node
del node_params
cad_file = _resolve_cad_file_context(session, workflow_context, state)
step_path = Path(cad_file.stored_path)
stl_dir = step_path.parent / "stl_cache"
payload = _serialize_cad_file_result(cad_file)
payload.update(
{
"cache_mode": "compatibility_noop",
"cache_required": False,
"stl_cache_dir": str(stl_dir),
"reason": "HartOMat CAD graph uses direct OCC/GLB export instead of legacy STL cache generation.",
}
)
return payload, "completed", None
def _execute_thumbnail_render_request(
*,
session: Session,
workflow_context: WorkflowContext,
state: WorkflowGraphState,
node,
node_params: dict[str, Any],
renderer: str,
) -> tuple[dict[str, Any], str, str | None]:
del node
cad_file = _resolve_cad_file_context(session, workflow_context, state)
payload: dict[str, Any] = {
"cad_file_id": str(cad_file.id),
"step_path": cad_file.stored_path,
"renderer": renderer,
"thumbnail_request": True,
}
for key in ("width", "height", "transparent_bg", "render_engine", "samples"):
value = node_params.get(key)
if value not in (None, ""):
payload[key] = value
return payload, "completed", None
def _execute_blender_thumbnail_render(
*,
session: Session,
workflow_context: WorkflowContext,
state: WorkflowGraphState,
node,
node_params: dict[str, Any],
) -> tuple[dict[str, Any], str, str | None]:
return _execute_thumbnail_render_request(
session=session,
workflow_context=workflow_context,
state=state,
node=node,
node_params=node_params,
renderer="blender",
)
def _execute_threejs_thumbnail_render(
*,
session: Session,
workflow_context: WorkflowContext,
state: WorkflowGraphState,
node,
node_params: dict[str, Any],
) -> tuple[dict[str, Any], str, str | None]:
return _execute_thumbnail_render_request(
session=session,
workflow_context=workflow_context,
state=state,
node=node,
node_params=node_params,
renderer="threejs",
)
def _execute_output_save(
*,
session: Session,
workflow_context: WorkflowContext,
state: WorkflowGraphState,
node,
node_params: dict[str, Any],
) -> tuple[dict[str, Any], str, str | None]:
del session, node_params
if state.setup is None or state.setup.order_line is None:
raise WorkflowGraphRuntimeError("output_save requires an order_line_setup result")
if state.setup.status == "skip":
return _serialize_setup_result(state.setup), "skipped", state.setup.reason
if not state.setup.is_ready:
return _serialize_setup_result(state.setup), "failed", state.setup.reason or "output_save_blocked"
order_line = state.setup.order_line
payload: dict[str, Any] = {
"order_line_id": str(order_line.id),
"authoritative_result_path": order_line.result_path,
"shadow_mode": workflow_context.execution_mode == "shadow",
}
upstream_artifacts = _connected_upstream_artifacts(workflow_context, state, node.id)
if workflow_context.execution_mode == "shadow":
payload["publication_mode"] = "shadow_observer_only"
elif any(artifact["publish_asset_enabled"] for artifact in upstream_artifacts):
payload["publication_mode"] = "deferred_to_render_task"
else:
payload["publication_mode"] = "awaiting_graph_authoritative_save"
if upstream_artifacts:
payload["artifact_count"] = len(upstream_artifacts)
payload["upstream_artifacts"] = upstream_artifacts
if state.template is not None and state.template.template is not None:
payload["template_name"] = state.template.template.name
if state.materials is not None:
payload["material_map_count"] = len(state.materials.material_map or {})
return payload, "completed", None
def _execute_notify(
*,
session: Session,
workflow_context: WorkflowContext,
state: WorkflowGraphState,
node,
node_params: dict[str, Any],
) -> tuple[dict[str, Any], str, str | None]:
del session, node_params
if state.setup is None or state.setup.order_line is None:
raise WorkflowGraphRuntimeError("notify requires an order_line_setup result")
if state.setup.status == "skip":
return _serialize_setup_result(state.setup), "skipped", state.setup.reason
if not state.setup.is_ready:
return _serialize_setup_result(state.setup), "failed", state.setup.reason or "notify_blocked"
payload: dict[str, Any] = {
"order_line_id": str(state.setup.order_line.id),
"shadow_mode": workflow_context.execution_mode == "shadow",
"channel": "audit_log",
}
if workflow_context.execution_mode == "shadow":
payload["notification_mode"] = "shadow_suppressed"
return payload, "skipped", "shadow mode suppresses user notifications"
connected_artifacts = _connected_upstream_artifacts(workflow_context, state, node.id)
armed_node_ids = [
artifact["node_id"]
for artifact in connected_artifacts
if artifact["notify_handoff_enabled"]
]
if not armed_node_ids:
payload["notification_mode"] = "not_armed"
return payload, "skipped", "No graph render task is configured for notification handoff"
payload["notification_mode"] = "deferred_to_render_task"
payload["armed_node_ids"] = armed_node_ids
payload["armed_node_count"] = len(armed_node_ids)
return payload, "completed", None
_BRIDGE_EXECUTORS = {
StepName.RESOLVE_STEP_PATH: _execute_resolve_step_path,
StepName.BLENDER_RENDER: _execute_blender_thumbnail_render,
StepName.THREEJS_RENDER: _execute_threejs_thumbnail_render,
StepName.ORDER_LINE_SETUP: _execute_order_line_setup,
StepName.RESOLVE_TEMPLATE: _execute_resolve_template,
StepName.MATERIAL_MAP_RESOLVE: _execute_material_map_resolve,
StepName.AUTO_POPULATE_MATERIALS: _execute_auto_populate_materials,
StepName.GLB_BBOX: _execute_glb_bbox,
StepName.STL_CACHE_GENERATE: _execute_stl_cache_generate,
StepName.OUTPUT_SAVE: _execute_output_save,
StepName.NOTIFY: _execute_notify,
}
@@ -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:
File diff suppressed because it is too large Load Diff
@@ -17,28 +17,30 @@ from app.domains.products.models import CadFile, Product
from app.domains.rendering.models import OutputType, RenderTemplate
from app.domains.rendering.workflow_runtime_services import (
auto_populate_materials_for_cad,
build_order_line_render_invocation,
emit_order_line_render_notifications,
MaterialResolutionResult,
OrderLineRenderSetupResult,
persist_order_line_media_asset,
persist_order_line_output,
resolve_cad_bbox,
prepare_order_line_render_context,
resolve_order_line_material_map,
resolve_order_line_template_context,
RenderPositionContext,
TemplateResolutionResult,
)
from app.domains.tenants.models import Tenant
import app.models # noqa: F401
TEST_DB_URL = os.environ.get(
"TEST_DATABASE_URL",
"postgresql+asyncpg://hartomat:hartomat@localhost:5432/hartomat_test",
).replace("+asyncpg", "")
from tests.db_test_utils import reset_public_schema_sync, resolve_test_db_url
@pytest.fixture
def sync_session():
engine = create_engine(TEST_DB_URL)
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)
@@ -47,8 +49,7 @@ def sync_session():
finally:
session.close()
with engine.begin() as conn:
conn.execute(text("DROP SCHEMA public CASCADE"))
conn.execute(text("CREATE SCHEMA public"))
reset_public_schema_sync(conn)
engine.dispose()
@@ -121,6 +122,13 @@ def test_prepare_order_line_render_context_marks_line_processing_and_prefers_usd
upload_dir.mkdir(parents=True, exist_ok=True)
line = _seed_order_line_graph(sync_session, tmp_path)
line.product.cad_file.resolved_material_assignments = {
"inner_ring": {
"source_name": "InnerRing",
"prim_path": "/Root/Assembly/inner_ring",
"canonical_material": "HARTOMAT_010101_Steel-Bare",
}
}
usd_asset_path = upload_dir / "usd" / "bearing.usd"
usd_asset_path.parent.mkdir(parents=True, exist_ok=True)
usd_asset_path.write_text("USD", encoding="utf-8")
@@ -159,6 +167,82 @@ def test_prepare_order_line_render_context_marks_line_processing_and_prefers_usd
assert any("Using USD master for render" in message for message in messages)
def test_prepare_order_line_render_context_queues_refresh_for_legacy_usd(sync_session, tmp_path, monkeypatch):
from app.config import settings
monkeypatch.setattr(settings, "upload_dir", str(tmp_path / "uploads"))
upload_dir = Path(settings.upload_dir)
upload_dir.mkdir(parents=True, exist_ok=True)
line = _seed_order_line_graph(sync_session, tmp_path)
line.product.cad_file.resolved_material_assignments = {
"inner_ring": {
"source_name": "InnerRing",
"prim_path": "/Root/Assembly/inner_ring",
"canonical_material": "SCHAEFFLER_010101_Steel-Bare",
}
}
usd_asset_path = upload_dir / "usd" / "bearing.usd"
usd_asset_path.parent.mkdir(parents=True, exist_ok=True)
usd_asset_path.write_text("USD", encoding="utf-8")
glb_asset_path = upload_dir / "step_files" / "bearing_thumbnail.glb"
glb_asset_path.parent.mkdir(parents=True, exist_ok=True)
glb_asset_path.write_text("GLB", encoding="utf-8")
sync_session.add_all(
[
MediaAsset(
id=uuid.uuid4(),
cad_file_id=line.product.cad_file_id,
product_id=line.product_id,
asset_type=MediaAssetType.usd_master,
storage_key="usd/bearing.usd",
),
MediaAsset(
id=uuid.uuid4(),
cad_file_id=line.product.cad_file_id,
product_id=line.product_id,
asset_type=MediaAssetType.gltf_geometry,
storage_key="step_files/bearing_thumbnail.glb",
),
]
)
sync_session.commit()
queued: list[str] = []
messages: list[str] = []
class _Task:
@staticmethod
def delay(cad_file_id: str) -> None:
queued.append(cad_file_id)
monkeypatch.setattr(
"app.tasks.step_tasks.generate_usd_master_task",
_Task(),
)
result = prepare_order_line_render_context(
sync_session,
str(line.id),
emit=lambda order_line_id, message, level=None: messages.append(message),
)
sync_session.refresh(line)
expected_glb = tmp_path / "parts" / "bearing_thumbnail.glb"
assert result.is_ready
assert result.usd_render_path is None
assert result.glb_reuse_path == expected_glb
assert expected_glb.exists()
assert queued == [str(line.product.cad_file_id)]
assert any("stale" in message for message in messages)
assert any("Queued USD master regeneration" in message for message in messages)
assert any("Reusing cached GLB geometry" in message for message in messages)
assert line.render_status == "processing"
def test_prepare_order_line_render_context_skips_closed_orders(sync_session, tmp_path, monkeypatch):
from app.config import settings
@@ -175,18 +259,262 @@ def test_prepare_order_line_render_context_skips_closed_orders(sync_session, tmp
assert line.render_status == "cancelled"
def test_build_order_line_render_invocation_applies_output_and_line_overrides(tmp_path):
step_path = tmp_path / "parts" / "bearing.step"
step_path.parent.mkdir(parents=True, exist_ok=True)
step_path.write_text("STEP", encoding="utf-8")
output_type = OutputType(
id=uuid.uuid4(),
name="Still Preview",
renderer="blender",
output_format="png",
render_settings={"width": 1600, "height": 900},
transparent_bg=False,
cycles_device="cpu",
)
output_type.invocation_overrides = {
"engine": "cycles",
"samples": 128,
"bg_color": "#202020",
"turntable_axis": "world_y",
"noise_threshold": "0.05",
}
cad_file = CadFile(
id=uuid.uuid4(),
original_name="bearing.step",
stored_path=str(step_path),
file_hash="hash-1",
parsed_objects={"objects": ["InnerRing", "OuterRing"]},
)
product = Product(
id=uuid.uuid4(),
pim_id="P-1000",
name="Bearing A",
category_key="bearings",
cad_file_id=cad_file.id,
cad_file=cad_file,
)
line = OrderLine(
id=uuid.uuid4(),
order_id=uuid.uuid4(),
product_id=product.id,
product=product,
output_type_id=output_type.id,
output_type=output_type,
render_overrides={
"height": 800,
"samples": 48,
"transparent_bg": True,
"cycles_device": "cuda",
"denoiser": "OPENIMAGEDENOISE",
"output_format": "webp",
},
)
setup = OrderLineRenderSetupResult(
status="ready",
order_line=line,
cad_file=cad_file,
part_colors={"InnerRing": "Steel raw", "OuterRing": "Steel raw"},
)
template = RenderTemplate(
id=uuid.uuid4(),
name="Studio",
blend_file_path="/templates/studio.blend",
original_filename="studio.blend",
target_collection="Assembly",
lighting_only=True,
shadow_catcher_enabled=True,
camera_orbit=False,
)
invocation = build_order_line_render_invocation(
setup,
template_context=TemplateResolutionResult(
template=template,
material_library="/libraries/materials.blend",
material_map={"InnerRing": "SteelPolished"},
use_materials=True,
override_material="Studio White",
category_key="bearings",
output_type_id=str(output_type.id),
),
position_context=RenderPositionContext(
rotation_x=12.0,
rotation_y=24.0,
rotation_z=36.0,
focal_length_mm=50.0,
sensor_width_mm=36.0,
),
)
assert invocation.output_extension == "webp"
assert invocation.output_filename.endswith(".webp")
assert invocation.width == 1600
assert invocation.height == 800
assert invocation.engine == "cycles"
assert invocation.samples == 48
assert invocation.noise_threshold == "0.05"
assert invocation.denoiser == "OPENIMAGEDENOISE"
assert invocation.transparent_bg is True
assert invocation.cycles_device == "cuda"
assert invocation.bg_color == "#202020"
assert invocation.turntable_axis == "world_y"
assert invocation.template_path == "/templates/studio.blend"
assert invocation.target_collection == "Assembly"
assert invocation.material_library_path == "/libraries/materials.blend"
assert invocation.material_map == {"InnerRing": "SteelPolished"}
assert invocation.material_override == "Studio White"
assert invocation.lighting_only is True
assert invocation.shadow_catcher is True
assert invocation.camera_orbit is False
assert invocation.part_names_ordered == ["InnerRing", "OuterRing"]
assert invocation.rotation_x == 12.0
assert invocation.focal_length_mm == 50.0
still_kwargs = invocation.as_still_renderer_kwargs(
step_path=str(step_path),
output_path=str(tmp_path / "renders" / "bearing.webp"),
job_id="job-1",
order_line_id="line-1",
)
assert still_kwargs["step_path"] == str(step_path)
assert still_kwargs["output_path"].endswith("bearing.webp")
assert still_kwargs["width"] == 1600
assert still_kwargs["height"] == 800
assert still_kwargs["engine"] == "cycles"
assert still_kwargs["samples"] == 48
assert still_kwargs["cycles_device"] == "cuda"
assert still_kwargs["material_library_path"] == "/libraries/materials.blend"
assert still_kwargs["material_override"] == "Studio White"
assert still_kwargs["job_id"] == "job-1"
assert still_kwargs["order_line_id"] == "line-1"
def test_build_order_line_render_invocation_autoscales_samples_and_prefers_material_context(
tmp_path,
):
step_path = tmp_path / "parts" / "bearing.step"
step_path.parent.mkdir(parents=True, exist_ok=True)
step_path.write_text("STEP", encoding="utf-8")
output_type = OutputType(
id=uuid.uuid4(),
name="Still Preview",
renderer="blender",
output_format="png",
render_settings={"width": 1024, "height": 512},
)
output_type.invocation_overrides = {"samples": 128, "engine": "eevee"}
cad_file = CadFile(
id=uuid.uuid4(),
original_name="bearing.step",
stored_path=str(step_path),
file_hash="hash-2",
parsed_objects={"objects": ["InnerRing", "OuterRing"]},
)
product = Product(
id=uuid.uuid4(),
pim_id="P-1001",
name="Bearing B",
category_key="bearings",
cad_file_id=cad_file.id,
cad_file=cad_file,
)
line = OrderLine(
id=uuid.uuid4(),
order_id=uuid.uuid4(),
product_id=product.id,
product=product,
output_type_id=output_type.id,
output_type=output_type,
)
setup = OrderLineRenderSetupResult(
status="ready",
order_line=line,
cad_file=cad_file,
part_colors={"InnerRing": "Steel raw"},
)
template = RenderTemplate(
id=uuid.uuid4(),
name="Studio",
blend_file_path="/templates/studio.blend",
original_filename="studio.blend",
target_collection="Product",
)
invocation = build_order_line_render_invocation(
setup,
template_context=TemplateResolutionResult(
template=template,
material_library="/libraries/materials.blend",
material_map={"InnerRing": "TemplateSteel"},
use_materials=True,
override_material="Template White",
category_key="bearings",
output_type_id=str(output_type.id),
),
material_context=MaterialResolutionResult(
material_map={"InnerRing": "ResolvedSteel"},
use_materials=False,
override_material="Resolved White",
source_material_count=2,
resolved_material_count=1,
),
)
assert invocation.engine == "eevee"
assert invocation.samples == 64
assert invocation.material_map == {"InnerRing": "ResolvedSteel"}
assert invocation.material_override == "Resolved White"
assert invocation.material_library_path is None
turntable_kwargs = invocation.as_turntable_renderer_kwargs(
step_path=step_path,
output_path=tmp_path / "renders" / "bearing.mp4",
smooth_angle=30,
default_width=1920,
default_height=1920,
default_engine="cycles",
default_samples=256,
)
cinematic_kwargs = invocation.as_cinematic_renderer_kwargs(
step_path=step_path,
output_path=tmp_path / "renders" / "bearing-cinematic.mp4",
smooth_angle=30,
default_width=1920,
default_height=1080,
default_engine="cycles",
default_samples=256,
)
assert turntable_kwargs["width"] == 1024
assert turntable_kwargs["height"] == 512
assert turntable_kwargs["engine"] == "eevee"
assert turntable_kwargs["samples"] == 64
assert turntable_kwargs["material_map"] == {"InnerRing": "ResolvedSteel"}
assert turntable_kwargs["material_library_path"] is None
assert cinematic_kwargs["width"] == 1024
assert cinematic_kwargs["height"] == 512
assert cinematic_kwargs["engine"] == "eevee"
assert cinematic_kwargs["samples"] == 64
assert cinematic_kwargs["material_override"] == "Resolved White"
def test_resolve_order_line_template_context_uses_exact_template_and_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 = "HARTOMAT_OVERRIDE"
material_library_path = tmp_path / "libraries" / "materials.blend"
material_library_path.parent.mkdir(parents=True, exist_ok=True)
material_library_path.write_text("BLEND", encoding="utf-8")
sync_session.add(
AssetLibrary(
id=uuid.uuid4(),
name="Default Library",
blend_file_path="/libraries/materials.blend",
blend_file_path=str(material_library_path),
is_active=True,
)
)
@@ -215,7 +543,7 @@ def test_resolve_order_line_template_context_uses_exact_template_and_override(sy
assert result.template is not None
assert result.template.name == "Bearing Studio"
assert result.material_library == "/libraries/materials.blend"
assert result.material_library == str(material_library_path)
assert result.override_material == "HARTOMAT_OVERRIDE"
assert result.use_materials is True
assert result.material_map == {
@@ -522,6 +850,79 @@ def test_persist_order_line_output_reuses_existing_asset(sync_session, tmp_path,
assert len(assets) == 1
def test_persist_order_line_output_canonicalizes_step_file_outputs(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)
step_render_path = upload_dir / "step_files" / "renders" / f"line_{line.id}.png"
step_render_path.parent.mkdir(parents=True, exist_ok=True)
step_render_path.write_text("PNGDATA", encoding="utf-8")
existing = MediaAsset(
id=uuid.uuid4(),
order_line_id=line.id,
product_id=line.product_id,
asset_type=MediaAssetType.still,
storage_key=f"renders/{line.id}/bearing.png",
)
sync_session.add(existing)
sync_session.commit()
result = persist_order_line_output(
sync_session,
line,
success=True,
output_path=str(step_render_path),
render_log={"renderer": "blender", "engine_used": "cycles"},
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 == existing.id)
).scalar_one()
assert expected_path.exists()
assert expected_path.read_text(encoding="utf-8") == "PNGDATA"
assert expected_path.parent == upload_dir / "renders" / str(line.id)
assert expected_path.name.startswith("Bearing_A_Still-")
assert expected_path.suffix == ".png"
assert result.result_path == str(expected_path)
assert result.storage_key == f"renders/{line.id}/{expected_path.name}"
assert line.result_path == str(expected_path)
assert result.asset_id == str(existing.id)
assert asset.storage_key == f"renders/{line.id}/{expected_path.name}"
def test_persist_order_line_output_checks_order_completion(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 / "rendered.png"
rendered.write_text("PNGDATA", encoding="utf-8")
calls: list[str] = []
monkeypatch.setattr(
"app.domains.orders.service.check_order_completion",
lambda order_id: calls.append(order_id) or True,
)
persist_order_line_output(
sync_session,
line,
success=True,
output_path=str(rendered),
render_log={"renderer": "blender"},
)
assert calls == [str(line.order_id)]
def test_persist_order_line_output_marks_failure_without_result_path(sync_session, tmp_path):
line = _seed_order_line_graph(sync_session, tmp_path)
@@ -547,6 +948,47 @@ def test_persist_order_line_output_marks_failure_without_result_path(sync_sessio
assert assets == []
def test_persist_order_line_media_asset_creates_blend_asset_without_touching_order_line(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)
line.render_status = "completed"
line.result_path = str(upload_dir / "renders" / str(line.id) / "bearing.png")
sync_session.commit()
output_path = upload_dir / "exports" / str(line.id) / "bearing_production.blend"
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text("BLENDDATA", encoding="utf-8")
result = persist_order_line_media_asset(
sync_session,
line,
success=True,
output_path=str(output_path),
asset_type=MediaAssetType.blend_production,
render_log={"artifact_type": "blend_production"},
)
sync_session.refresh(line)
asset = sync_session.execute(
select(MediaAsset).where(MediaAsset.storage_key == f"exports/{line.id}/bearing_production.blend")
).scalar_one_or_none()
assert result.status == "completed"
assert result.result_path == str(output_path)
assert result.storage_key == f"exports/{line.id}/bearing_production.blend"
assert result.asset_type == MediaAssetType.blend_production
assert line.render_status == "completed"
assert line.result_path == str(upload_dir / "renders" / str(line.id) / "bearing.png")
assert asset is not None
assert asset.asset_type == MediaAssetType.blend_production
assert asset.mime_type == "application/x-blender"
assert asset.file_size_bytes == output_path.stat().st_size
assert asset.render_config == {"artifact_type": "blend_production"}
def test_emit_order_line_render_notifications_emits_websocket_and_activity(
sync_session,
tmp_path,