fix: animation output types now render turntable mp4 via Celery

- render_order_line_task: detect is_animation flag, branch to turntable
  pipeline instead of still-image pipeline
- render_blender: add render_turntable_to_file() — STL conversion,
  blender turntable_render.py for frames, ffmpeg compose to mp4
- render-worker/Dockerfile: add ffmpeg package
- Fix: Turntable Video_White was producing a .jpg still instead of .mp4

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 20:10:33 +01:00
parent d138bc4bc4
commit ceb0143cb6
3 changed files with 339 additions and 48 deletions
+125 -48
View File
@@ -362,11 +362,16 @@ def render_order_line_task(self, order_line_id: str):
emit(order_line_id, f"Starting render for {cad_name} ({len(part_colors)} coloured parts)")
# Determine output format from output_type (default jpg)
# Determine if this is an animation output type
is_animation = bool(line.output_type and getattr(line.output_type, 'is_animation', False))
# 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 in ("png", "jpg", "jpeg"):
if fmt == "mp4":
out_ext = "mp4"
elif fmt in ("png", "jpg", "jpeg"):
out_ext = "png" if fmt == "png" else "jpg"
# Build meaningful output filename
@@ -378,28 +383,15 @@ def render_order_line_task(self, order_line_id: str):
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 (not the shared CadFile thumbnail)
# 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 resolution from render_settings
# Extract per-output-type render settings
render_width = None
render_height = None
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"])
# Check if transparent background is requested
transparent_bg = False
if line.output_type and line.output_type.transparent_bg:
transparent_bg = True
# Extract per-OT engine and samples overrides
render_engine = None
render_samples = None
noise_threshold = ""
@@ -408,12 +400,26 @@ def render_order_line_task(self, order_line_id: str):
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", ""))
@@ -421,42 +427,113 @@ def render_order_line_task(self, order_line_id: str):
denoising_quality = str(rs.get("denoising_quality", ""))
denoising_use_gpu = str(rs.get("denoising_use_gpu", ""))
tmpl_info = f" template={template.name}" if template else ""
emit(order_line_id, f"Calling renderer (STEP → STL → render) {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}")
from app.services.step_processor import render_to_file
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"
# 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
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,
job_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,
)
tmpl_info = f" template={template.name}" if template else ""
if is_animation:
# ── Turntable animation path ────────────────────────────────
emit(order_line_id, f"Starting turntable render: {frame_count} frames @ {fps}fps, {render_width or 1920}x{render_height or 1920}{tmpl_info}")
from app.services.render_blender import is_blender_available, render_turntable_to_file
if not is_blender_available():
raise RuntimeError("Blender not available on this worker")
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),
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)),
stl_quality=_sys.get("stl_quality", "low"),
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,
)
success = True
render_log = {
"renderer": "blender",
"type": "turntable",
"format": "mp4",
"engine": render_engine or _sys.get("blender_engine", "cycles"),
"engine_used": service_data.get("engine_used", "cycles"),
"samples": render_samples,
"cycles_device": cycles_device_val,
"width": render_width or 1920,
"height": render_height or 1920,
"frame_count": service_data.get("frame_count", frame_count),
"fps": fps,
"total_duration_s": service_data.get("total_duration_s"),
"stl_duration_s": service_data.get("stl_duration_s"),
"render_duration_s": service_data.get("render_duration_s"),
"ffmpeg_duration_s": service_data.get("ffmpeg_duration_s"),
"stl_size_bytes": service_data.get("stl_size_bytes"),
"output_size_bytes": service_data.get("output_size_bytes"),
"log_lines": service_data.get("log_lines", []),
}
if template:
render_log["template"] = template.blend_file_path
except Exception as exc:
success = False
render_log = {"renderer": "blender", "type": "turntable", "error": str(exc)[:500]}
logger.error("Turntable render failed for %s: %s", order_line_id, exc)
else:
# ── Still image path ────────────────────────────────────────
emit(order_line_id, f"Calling renderer (STEP → STL → still) {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}")
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,
job_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,
)
new_status = "completed" if success else "failed"
render_end = datetime.utcnow()