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:
@@ -332,3 +332,216 @@ def render_still(
|
|||||||
"engine_used": engine_used,
|
"engine_used": engine_used,
|
||||||
"log_lines": log_lines,
|
"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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)")
|
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"
|
out_ext = "jpg"
|
||||||
if line.output_type and line.output_type.output_format:
|
if line.output_type and line.output_type.output_format:
|
||||||
fmt = line.output_type.output_format.lower()
|
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"
|
out_ext = "png" if fmt == "png" else "jpg"
|
||||||
|
|
||||||
# Build meaningful output filename
|
# 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"
|
ot_name = line.output_type.name if line.output_type else "render"
|
||||||
filename = f"{_sanitize(product_name)}_{_sanitize(ot_name)}.{out_ext}"
|
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
|
from pathlib import Path as _Path
|
||||||
render_dir = _Path(app_settings.upload_dir) / "renders" / order_line_id
|
render_dir = _Path(app_settings.upload_dir) / "renders" / order_line_id
|
||||||
render_dir.mkdir(parents=True, exist_ok=True)
|
render_dir.mkdir(parents=True, exist_ok=True)
|
||||||
output_path = str(render_dir / filename)
|
output_path = str(render_dir / filename)
|
||||||
|
|
||||||
# Extract per-output-type resolution from render_settings
|
# Extract per-output-type render settings
|
||||||
render_width = None
|
render_width = None
|
||||||
render_height = 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_engine = None
|
||||||
render_samples = None
|
render_samples = None
|
||||||
noise_threshold = ""
|
noise_threshold = ""
|
||||||
@@ -408,12 +400,26 @@ def render_order_line_task(self, order_line_id: str):
|
|||||||
denoising_prefilter = ""
|
denoising_prefilter = ""
|
||||||
denoising_quality = ""
|
denoising_quality = ""
|
||||||
denoising_use_gpu = ""
|
denoising_use_gpu = ""
|
||||||
|
frame_count = 24
|
||||||
|
fps = 25
|
||||||
|
bg_color = ""
|
||||||
|
turntable_axis = "world_z"
|
||||||
if line.output_type and line.output_type.render_settings:
|
if line.output_type and line.output_type.render_settings:
|
||||||
rs = 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"):
|
if rs.get("engine"):
|
||||||
render_engine = rs["engine"]
|
render_engine = rs["engine"]
|
||||||
if rs.get("samples"):
|
if rs.get("samples"):
|
||||||
render_samples = int(rs["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", ""))
|
noise_threshold = str(rs.get("noise_threshold", ""))
|
||||||
denoiser = str(rs.get("denoiser", ""))
|
denoiser = str(rs.get("denoiser", ""))
|
||||||
denoising_input_passes = str(rs.get("denoising_input_passes", ""))
|
denoising_input_passes = str(rs.get("denoising_input_passes", ""))
|
||||||
@@ -421,14 +427,85 @@ def render_order_line_task(self, order_line_id: str):
|
|||||||
denoising_quality = str(rs.get("denoising_quality", ""))
|
denoising_quality = str(rs.get("denoising_quality", ""))
|
||||||
denoising_use_gpu = str(rs.get("denoising_use_gpu", ""))
|
denoising_use_gpu = str(rs.get("denoising_use_gpu", ""))
|
||||||
|
|
||||||
tmpl_info = f" template={template.name}" if template else ""
|
transparent_bg = bool(line.output_type and line.output_type.transparent_bg)
|
||||||
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}")
|
cycles_device_val = (line.output_type.cycles_device or "auto") if line.output_type else "auto"
|
||||||
from app.services.step_processor import render_to_file
|
|
||||||
# Build ordered part names list for index-based Blender matching
|
# Build ordered part names list for index-based Blender matching
|
||||||
part_names_ordered = None
|
part_names_ordered = None
|
||||||
if cad_file and cad_file.parsed_objects:
|
if cad_file and cad_file.parsed_objects:
|
||||||
part_names_ordered = cad_file.parsed_objects.get("objects", []) or None
|
part_names_ordered = cad_file.parsed_objects.get("objects", []) or None
|
||||||
|
|
||||||
|
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(
|
success, render_log = render_to_file(
|
||||||
step_path=cad_file.stored_path,
|
step_path=cad_file.stored_path,
|
||||||
output_path=output_path,
|
output_path=output_path,
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
libegl1 \
|
libegl1 \
|
||||||
libegl-mesa0 \
|
libegl-mesa0 \
|
||||||
libgbm1 \
|
libgbm1 \
|
||||||
|
ffmpeg \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
Reference in New Issue
Block a user