feat: cinematic highlight render — 20s procedural camera animation

New render type: 4-segment cinematic camera animation (480 frames @ 24fps)
for professional product highlight videos.

Camera sequence:
1. Establishing (5s): slow 45° orbit + push-in, 50mm lens
2. Detail sweep (5s): low-angle close arc, 85mm telephoto, shallow DOF
3. Crane up (5s): rising 30°→60°, 35mm wide reveal, pull-back
4. Hero close (5s): push-in to beauty angle, 65mm, smooth ease-out

Technical:
- cinematic_render.py: procedural camera from bounding sphere, cubic easing,
  per-frame keyframes (location, rotation, focal length, DOF)
- render_cinematic_to_file(): service function (same pattern as turntable)
- Pipeline routing: render_settings.cinematic flag → cinematic path
- Depth of field enabled (f-stop scales with product size)
- use_persistent_data for BVH caching between frames
- Same material/template/USD pipeline as turntable

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-15 21:25:56 +01:00
parent c82f2a894d
commit f22b963be9
4 changed files with 1210 additions and 105 deletions
@@ -300,6 +300,13 @@ def render_order_line_task(self, order_line_id: str):
# 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")
)
# Determine output format/extension
out_ext = "jpg"
if line.output_type and line.output_type.output_format:
@@ -434,7 +441,75 @@ def render_order_line_task(self, order_line_id: str):
tmpl_info = f" template={template.name}" if template else ""
if is_animation:
if is_cinematic:
# ── Cinematic highlight animation path ──────────────────────
_cine_fps = 24
_cine_frames = 480
emit(order_line_id, f"Starting cinematic render: {_cine_frames} frames @ {_cine_fps}fps, {render_width or 1920}x{render_height or 1080}{tmpl_info}")
pl.step_start("blender_cinematic", {"frame_count": _cine_frames, "fps": _cine_fps})
from app.services.render_blender import is_blender_available, render_cinematic_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_cinematic_to_file(
step_path=_Path(cad_file.stored_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)),
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,
)
success = True
render_log = {
"renderer": "blender",
"type": "cinematic",
"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 1080,
"frame_count": service_data.get("frame_count", _cine_frames),
"fps": _cine_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
pl.step_done("blender_cinematic")
except Exception as exc:
success = False
render_log = {"renderer": "blender", "type": "cinematic", "error": str(exc)[:500]}
pl.step_error("blender_cinematic", str(exc), exc)
logger.error("Cinematic render failed for %s: %s", order_line_id, exc)
elif 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}")
pl.step_start("blender_turntable", {"frame_count": frame_count, "fps": fps})
+206
View File
@@ -518,3 +518,209 @@ def render_turntable_to_file(
"engine_used": engine,
"log_lines": log_lines,
}
def render_cinematic_to_file(
step_path: Path,
output_path: Path,
width: int = 1920,
height: int = 1080,
engine: str = "cycles",
samples: int = 128,
smooth_angle: int = 30,
cycles_device: str = "auto",
transparent_bg: bool = False,
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,
usd_path: "Path | None" = None,
tessellation_engine: str = "occ",
focal_length_mm: float | None = None,
sensor_width_mm: float | None = None,
material_override: str | None = None,
log_callback: "Callable[[str], None] | None" = None,
) -> dict:
"""Render a cinematic highlight animation: STEP -> GLB/USD -> 480 frames @ 24fps (Blender) -> mp4 (ffmpeg).
Fixed at 24fps, 480 frames (20 seconds). Uses cinematic_render.py which
creates a procedural 4-segment camera animation with varying focal lengths,
elevations, and bezier-eased transitions.
When usd_path is provided and exists, the GLB conversion step is skipped.
Returns a dict with timing, frame count, engine_used, log_lines.
Raises RuntimeError on failure.
"""
import shutil as _shutil
import time
# Cinematic parameters are fixed
frame_count = 480
fps = 24
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")) / "cinematic_render.py"
if not script_path.exists():
alt = Path(__file__).parent.parent.parent.parent / "render-worker" / "scripts" / "cinematic_render.py"
if alt.exists():
script_path = alt
else:
raise RuntimeError(f"cinematic_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. GLB conversion (OCC) — skipped when usd_path is provided
glb_path = step_path.parent / f"{step_path.stem}_thumbnail.glb"
use_usd = bool(usd_path and usd_path.exists())
t_glb = time.monotonic()
if use_usd:
logger.info("[render_blender] cinematic using USD path: %s", usd_path)
else:
if not glb_path.exists() or glb_path.stat().st_size == 0:
_glb_from_step(step_path, glb_path, tessellation_engine)
else:
logger.info("GLB local hit: %s (%d KB)", glb_path.name, glb_path.stat().st_size // 1024)
glb_duration_s = round(time.monotonic() - t_glb, 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"
glb_arg = "" if use_usd else str(glb_path)
cmd = [
blender_bin,
"--background",
"--python", str(script_path),
"--",
glb_arg,
str(frames_dir),
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),
]
if use_usd:
cmd += ["--usd-path", str(usd_path)]
if focal_length_mm is not None:
cmd += ["--focal-length", str(focal_length_mm)]
if sensor_width_mm is not None:
cmd += ["--sensor-width", str(sensor_width_mm)]
if material_override:
cmd += ["--material-override", material_override]
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=7200) # 2hr max for cinematic (480 frames)
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("[cinematic] %s", line)
if "[cinematic_render]" in line:
log_lines.append(line)
if log_callback:
log_callback(line)
for line in (stderr or "").splitlines():
logger.warning("[cinematic stderr] %s", line)
if proc.returncode != 0:
raise RuntimeError(
f"cinematic_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("Cinematic 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),
]
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": glb_duration_s,
"render_duration_s": render_duration_s,
"ffmpeg_duration_s": ffmpeg_duration_s,
"stl_size_bytes": 0,
"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,
}