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")