Spaces:
Running on Zero
Running on Zero
| """ | |
| Rendering entry points. | |
| - ``render_blend_gpu``: Cycles CUDA/OptiX render inside a ZeroGPU worker. The Blender | |
| subprocess must be spawned *inside* the ``@spaces.GPU`` function so it inherits the | |
| worker's CUDA devices. | |
| - ``render_blend_local``: CPU Cycles (reliable) or EEVEE via Mesa software GL | |
| (experimental, slow) in the main container. | |
| """ | |
| from __future__ import annotations | |
| import os | |
| import subprocess | |
| import spaces | |
| from blender_session import BLENDER_BIN, USER_RESOURCES, wrap_with_memory_cap | |
| RENDER_SCRIPT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "render_script.py") | |
| _MAX_CPU_SAMPLES = 256 | |
| DEFAULT_CAMERA = {"mode": "scene", "azimuth": 315.0, "elevation": 30.0, "zoom": 1.0, "focal": 50.0} | |
| def _run_blender_render( | |
| blend_path: str, | |
| out_path: str, | |
| engine: str, | |
| samples: int, | |
| res_x: int, | |
| res_y: int, | |
| camera: dict | None = None, | |
| extra_env: dict[str, str] | None = None, | |
| timeout: float = 600.0, | |
| anim: dict | None = None, | |
| ) -> str: | |
| env = {**os.environ, "BLENDER_USER_RESOURCES": USER_RESOURCES} | |
| if extra_env: | |
| env.update(extra_env) | |
| cam = {**DEFAULT_CAMERA, **(camera or {})} | |
| cmd = [ | |
| BLENDER_BIN, "-b", blend_path, "--python", RENDER_SCRIPT, "--", | |
| out_path, engine, str(samples), str(res_x), str(res_y), | |
| str(cam["mode"]), str(cam["azimuth"]), str(cam["elevation"]), | |
| str(cam["zoom"]), str(cam["focal"]), | |
| ] | |
| if anim is not None: | |
| cmd += [ | |
| "anim", str(anim.get("fps", 0)), | |
| str(anim.get("frame_start", 0)), str(anim.get("frame_end", 0)), | |
| ] | |
| proc = subprocess.run( | |
| wrap_with_memory_cap(cmd), | |
| env=env, capture_output=True, text=True, timeout=timeout, check=False, | |
| ) | |
| # Blender writes "file.png" possibly with frame suffix; render_script sets exact path. | |
| if proc.returncode != 0 or not os.path.exists(out_path): | |
| tail = "\n".join((proc.stdout + "\n" + proc.stderr).splitlines()[-30:]) | |
| raise RuntimeError(f"Render failed (exit {proc.returncode}):\n{tail}") | |
| for line in proc.stdout.splitlines(): | |
| if line.startswith("RENDER_DONE"): | |
| print(line) | |
| return out_path | |
| def estimate_render_duration( | |
| blend_path: str, out_path: str, samples: int, res_x: int, res_y: int, | |
| camera: dict | None = None, | |
| ) -> int: | |
| """ZeroGPU duration estimate: Blender startup + scene load + render time.""" | |
| base = 30.0 | |
| pixel_factor = (res_x * res_y) / (1920 * 1080) | |
| render = 60.0 * (samples / 128.0) * max(pixel_factor, 0.1) | |
| return int(min(max(base + render, 45.0), 300.0)) | |
| def render_blend_gpu( | |
| blend_path: str, out_path: str, samples: int, res_x: int, res_y: int, | |
| camera: dict | None = None, | |
| ) -> str: | |
| """Render a .blend with Cycles on the ZeroGPU worker's GPU (OptiX/CUDA).""" | |
| return _run_blender_render(blend_path, out_path, "CYCLES_GPU", samples, res_x, res_y, camera) | |
| def render_blend_local( | |
| blend_path: str, out_path: str, engine: str, samples: int, res_x: int, res_y: int, | |
| camera: dict | None = None, | |
| ) -> str: | |
| """CPU Cycles or experimental software-GL EEVEE render in the main container.""" | |
| if engine == "CYCLES_CPU": | |
| samples = min(samples, _MAX_CPU_SAMPLES) | |
| return _run_blender_render(blend_path, out_path, "CYCLES_CPU", samples, res_x, res_y, camera) | |
| if engine == "EEVEE": | |
| env = {"LIBGL_ALWAYS_SOFTWARE": "1", "GALLIUM_DRIVER": "llvmpipe"} | |
| return _run_blender_render( | |
| blend_path, out_path, "EEVEE", samples, res_x, res_y, camera, extra_env=env | |
| ) | |
| raise ValueError(f"Unknown engine: {engine}") | |
| # --- animation rendering ----------------------------------------------------- | |
| def estimate_animation_duration( | |
| blend_path: str, out_path: str, samples: int, res_x: int, res_y: int, | |
| fps: int = 0, frame_start: int = 0, frame_end: int = 0, | |
| camera: dict | None = None, | |
| ) -> int: | |
| """ | |
| ZeroGPU duration estimate for animations: startup + per-frame render cost. | |
| Kept as tight as realistic - over-declaring blocks low-quota visitors. | |
| """ | |
| frames = max((frame_end - frame_start + 1) if frame_end > frame_start else 72, 1) | |
| pixel_factor = max((res_x * res_y) / (1280 * 720), 0.1) | |
| per_frame = 1.0 * (samples / 64.0) * pixel_factor | |
| return int(min(max(35.0 + frames * per_frame, 60.0), 300.0)) | |
| def render_animation_gpu( | |
| blend_path: str, out_path: str, samples: int, res_x: int, res_y: int, | |
| fps: int = 0, frame_start: int = 0, frame_end: int = 0, | |
| camera: dict | None = None, | |
| ) -> str: | |
| """Render the scene's animation to MP4 with Cycles on the ZeroGPU worker.""" | |
| return _run_blender_render( | |
| blend_path, out_path, "CYCLES_GPU", samples, res_x, res_y, camera, | |
| timeout=340.0, | |
| anim={"fps": fps, "frame_start": frame_start, "frame_end": frame_end}, | |
| ) | |
| def render_animation_local( | |
| blend_path: str, out_path: str, engine: str, samples: int, res_x: int, res_y: int, | |
| fps: int = 0, frame_start: int = 0, frame_end: int = 0, | |
| camera: dict | None = None, | |
| ) -> str: | |
| """Workbench/EEVEE preview animation via software GL in the main container.""" | |
| if engine not in ("WORKBENCH", "EEVEE"): | |
| raise ValueError(f"Unknown animation preview engine: {engine}") | |
| env = {"LIBGL_ALWAYS_SOFTWARE": "1", "GALLIUM_DRIVER": "llvmpipe"} | |
| return _run_blender_render( | |
| blend_path, out_path, engine, samples, res_x, res_y, camera, | |
| extra_env=env, timeout=900.0, | |
| anim={"fps": fps, "frame_start": frame_start, "frame_end": frame_end}, | |
| ) | |