File size: 5,679 Bytes
26cd2e7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70207f8
26cd2e7
 
 
 
 
 
3fc629e
 
 
26cd2e7
 
 
 
 
 
 
3fc629e
26cd2e7
 
b8ccc90
26cd2e7
 
 
 
3fc629e
26cd2e7
 
 
3fc629e
 
26cd2e7
b8ccc90
 
 
 
 
70207f8
 
 
 
26cd2e7
 
 
 
 
 
 
 
 
 
 
3fc629e
 
26cd2e7
 
 
 
 
 
 
 
 
3fc629e
 
 
 
26cd2e7
3fc629e
26cd2e7
 
 
3fc629e
 
26cd2e7
 
 
 
3fc629e
26cd2e7
 
 
3fc629e
26cd2e7
 
b8ccc90
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
"""
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))


@spaces.GPU(duration=estimate_render_duration)
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))


@spaces.GPU(duration=estimate_animation_duration)
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},
    )