diff --git a/backend/app/services/render_blender.py b/backend/app/services/render_blender.py index 914d90d..6884d86 100644 --- a/backend/app/services/render_blender.py +++ b/backend/app/services/render_blender.py @@ -332,3 +332,216 @@ def render_still( "engine_used": engine_used, "log_lines": log_lines, } + + +def render_turntable_to_file( + step_path: Path, + output_path: Path, + frame_count: int = 24, + fps: int = 25, + width: int = 1920, + height: int = 1920, + engine: str = "cycles", + samples: int = 128, + stl_quality: str = "low", + smooth_angle: int = 30, + cycles_device: str = "auto", + transparent_bg: bool = False, + bg_color: str = "", + turntable_axis: str = "world_z", + part_colors: dict | None = None, + template_path: str | None = None, + target_collection: str = "Product", + material_library_path: str | None = None, + material_map: dict | None = None, + part_names_ordered: list | None = None, + lighting_only: bool = False, + shadow_catcher: bool = False, + rotation_x: float = 0.0, + rotation_y: float = 0.0, + rotation_z: float = 0.0, +) -> dict: + """Render a turntable animation: STEP → STL → N frames (Blender) → mp4 (ffmpeg). + + Returns a dict with timing, frame count, engine_used, log_lines. + Raises RuntimeError on failure. + """ + import shutil as _shutil + import tempfile + import time + + blender_bin = find_blender() + if not blender_bin: + raise RuntimeError("Blender binary not found — check BLENDER_BIN env or PATH") + + script_path = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts")) / "turntable_render.py" + if not script_path.exists(): + alt = Path(__file__).parent.parent.parent.parent / "render-worker" / "scripts" / "turntable_render.py" + if alt.exists(): + script_path = alt + else: + raise RuntimeError(f"turntable_render.py not found at {script_path}") + + ffmpeg_bin = _shutil.which("ffmpeg") + if not ffmpeg_bin: + raise RuntimeError("ffmpeg not found — install ffmpeg in the render-worker container") + + t0 = time.monotonic() + + # 1. STL conversion + stl_path = step_path.parent / f"{step_path.stem}_{stl_quality}.stl" + parts_dir = step_path.parent / f"{step_path.stem}_{stl_quality}_parts" + + t_stl = time.monotonic() + if not stl_path.exists() or stl_path.stat().st_size == 0: + logger.info("STL cache miss — converting: %s", step_path.name) + convert_step_to_stl(step_path, stl_path, stl_quality) + else: + logger.info("STL cache hit: %s (%d KB)", stl_path.name, stl_path.stat().st_size // 1024) + stl_size_bytes = stl_path.stat().st_size if stl_path.exists() else 0 + + if not (parts_dir / "manifest.json").exists(): + try: + export_per_part_stls(step_path, parts_dir, stl_quality) + except Exception as exc: + logger.warning("per-part STL export failed (non-fatal): %s", exc) + + stl_duration_s = round(time.monotonic() - t_stl, 2) + + # 2. Render frames with Blender + frames_dir = output_path.parent / f"_frames_{output_path.stem}" + frames_dir.mkdir(parents=True, exist_ok=True) + output_path.parent.mkdir(parents=True, exist_ok=True) + + env = dict(os.environ) + env["EGL_PLATFORM"] = "surfaceless" + + cmd = [ + blender_bin, + "--background", + "--python", str(script_path), + "--", + str(stl_path), + str(frames_dir), + str(frame_count), + "360", # degrees + str(width), str(height), + engine, str(samples), + json.dumps(part_colors or {}), + template_path or "", + target_collection, + material_library_path or "", + json.dumps(material_map) if material_map else "{}", + json.dumps(part_names_ordered) if part_names_ordered else "[]", + "1" if lighting_only else "0", + cycles_device, + "1" if shadow_catcher else "0", + str(rotation_x), str(rotation_y), str(rotation_z), + turntable_axis, + bg_color or "", + "1" if transparent_bg else "0", + ] + + log_lines: list[str] = [] + + t_render = time.monotonic() + proc = subprocess.Popen( + cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, env=env, start_new_session=True, + ) + try: + stdout, stderr = proc.communicate(timeout=3600) # 1hr max for full animation + except subprocess.TimeoutExpired: + try: + os.killpg(os.getpgid(proc.pid), signal.SIGTERM) + except (ProcessLookupError, OSError): + pass + stdout, stderr = proc.communicate() + + for line in (stdout or "").splitlines(): + logger.info("[turntable] %s", line) + if "[turntable_render]" in line: + log_lines.append(line) + for line in (stderr or "").splitlines(): + logger.warning("[turntable stderr] %s", line) + + if proc.returncode != 0: + raise RuntimeError( + f"turntable_render.py exited with code {proc.returncode}.\n" + f"stdout: {(stdout or '')[-2000:]}\n" + f"stderr: {(stderr or '')[-500:]}" + ) + + render_duration_s = round(time.monotonic() - t_render, 2) + + # Check frames were written + frame_files = sorted(frames_dir.glob("frame_*.png")) + if not frame_files: + raise RuntimeError(f"No frames rendered in {frames_dir}") + logger.info("Rendered %d frames in %.1fs", len(frame_files), render_duration_s) + + # 3. Compose frames → mp4 with ffmpeg + t_ffmpeg = time.monotonic() + + ffmpeg_cmd = [ + ffmpeg_bin, + "-y", + "-framerate", str(fps), + "-i", str(frames_dir / "frame_%04d.png"), + "-vcodec", "libx264", + "-pix_fmt", "yuv420p", + "-crf", "18", + "-movflags", "+faststart", + str(output_path), + ] + + # If bg_color is set and transparent_bg is True, overlay frames on solid bg + if bg_color and transparent_bg: + hex_color = bg_color.lstrip("#") + r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16) + ffmpeg_cmd = [ + ffmpeg_bin, "-y", + "-framerate", str(fps), + "-i", str(frames_dir / "frame_%04d.png"), + "-f", "lavfi", "-i", f"color=c=0x{hex_color}:size={width}x{height}:rate={fps}", + "-filter_complex", "[1:v][0:v]overlay=0:0", + "-vcodec", "libx264", + "-pix_fmt", "yuv420p", + "-crf", "18", + "-movflags", "+faststart", + str(output_path), + ] + + ffmpeg_proc = subprocess.run( + ffmpeg_cmd, capture_output=True, text=True, timeout=300 + ) + ffmpeg_duration_s = round(time.monotonic() - t_ffmpeg, 2) + + for line in (ffmpeg_proc.stdout or "").splitlines(): + logger.info("[ffmpeg] %s", line) + for line in (ffmpeg_proc.stderr or "").splitlines(): + logger.debug("[ffmpeg stderr] %s", line) + + if ffmpeg_proc.returncode != 0: + raise RuntimeError( + f"ffmpeg exited with code {ffmpeg_proc.returncode}.\n" + f"stderr: {(ffmpeg_proc.stderr or '')[-1000:]}" + ) + + # Clean up frames directory + try: + _shutil.rmtree(frames_dir) + except Exception: + pass + + return { + "total_duration_s": round(time.monotonic() - t0, 2), + "stl_duration_s": stl_duration_s, + "render_duration_s": render_duration_s, + "ffmpeg_duration_s": ffmpeg_duration_s, + "stl_size_bytes": stl_size_bytes, + "output_size_bytes": output_path.stat().st_size if output_path.exists() else 0, + "frame_count": len(frame_files), + "engine_used": engine, + "log_lines": log_lines, + } diff --git a/backend/app/tasks/step_tasks.py b/backend/app/tasks/step_tasks.py index 448ceab..35edab9 100644 --- a/backend/app/tasks/step_tasks.py +++ b/backend/app/tasks/step_tasks.py @@ -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() diff --git a/render-worker/Dockerfile b/render-worker/Dockerfile index 608a440..e3e2926 100644 --- a/render-worker/Dockerfile +++ b/render-worker/Dockerfile @@ -44,6 +44,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libegl1 \ libegl-mesa0 \ libgbm1 \ + ffmpeg \ && rm -rf /var/lib/apt/lists/* WORKDIR /app