File size: 17,489 Bytes
94a0de3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3a5873f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94a0de3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5d6dcc3
94a0de3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5d6dcc3
 
 
 
 
 
 
 
 
 
 
 
94a0de3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d443689
 
 
 
94a0de3
 
 
 
 
 
 
 
 
5d6dcc3
94a0de3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
"""安装可信 Blender 运行时,并在可取消的隔离子进程中执行固定渲染脚本。"""

from __future__ import annotations

import contextlib
import hashlib
import json
import os
import posixpath
import re
import shutil
import signal
import subprocess
import tarfile
import tempfile
import threading
import time
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any, Iterator


_INSTALL_LOCK = threading.Lock()
_VERSION_RE = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+$")
_WORKER = Path(__file__).resolve().with_name("render_worker.py")
_MAX_ARCHIVE_BYTES = 4 * 1024**3
_MAX_EXTRACTED_BYTES = 12 * 1024**3


def _valid_executable(path: Path) -> bool:
    return path.is_file() and os.access(path, os.X_OK)


@contextlib.contextmanager
def _installation_file_lock(path: Path) -> Iterator[None]:
    """Linux 进程间锁;与线程锁一起避免重复下载和并发解压。"""
    if os.name != "posix":
        raise RuntimeError("自动安装 Blender 目前只支持 Linux;请设置 BLENDER_BIN。")
    import fcntl

    with path.open("a+b") as lock_file:
        fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
        try:
            yield
        finally:
            fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)


def _download(url: str, destination: Path, maximum: int) -> None:
    """HTTPS 流式下载;首次失败后最多重试两次,每次重写临时文件。"""
    last_error: Exception | None = None
    for attempt in range(3):
        try:
            request = urllib.request.Request(
                url, headers={"User-Agent": "Blender-Render-Space/1.0"}
            )
            started = time.monotonic()
            with urllib.request.urlopen(request, timeout=60) as response:
                if not response.geturl().startswith("https://"):
                    raise RuntimeError("下载发生了非 HTTPS 重定向,已拒绝。")
                content_length = response.headers.get("Content-Length")
                if content_length and int(content_length) > maximum:
                    raise RuntimeError("下载内容超过允许大小。")
                total = 0
                with destination.open("wb") as output:
                    while block := response.read(1024 * 1024):
                        total += len(block)
                        if total > maximum:
                            raise RuntimeError("下载内容超过允许大小。")
                        if time.monotonic() - started > 1800:
                            raise TimeoutError("单次下载超过 30 分钟。")
                        output.write(block)
            return
        except (OSError, urllib.error.URLError, ValueError, RuntimeError) as error:
            last_error = error
            destination.unlink(missing_ok=True)
            if attempt < 2:
                time.sleep(attempt + 1)
    raise RuntimeError(f"无法下载 Blender 官方文件:{url};原因:{last_error}") from last_error


def _expected_digest(checksums: Path, filename: str) -> str:
    matches: set[str] = set()
    for line in checksums.read_text(encoding="utf-8").splitlines():
        match = re.fullmatch(r"([a-fA-F0-9]{64})\s+\*?(.+?)\s*", line)
        if match and match.group(2) == filename:
            matches.add(match.group(1).lower())
    if len(matches) != 1:
        raise RuntimeError(f"官方 SHA-256 清单中没有唯一匹配的 {filename},安装已停止。")
    return matches.pop()


def _safe_extract(archive: Path, destination: Path, archive_root: str) -> Path:
    """只接受官方根目录中的普通文件、目录和不越界的内部链接。"""
    with tarfile.open(archive, "r:xz") as bundle:
        members = bundle.getmembers()
        if len(members) > 200_000:
            raise RuntimeError("Blender 压缩包文件数量异常。")
        expanded_size = 0
        seen: set[str] = set()
        for member in members:
            name = member.name.rstrip("/")
            parts = name.split("/")
            if (
                not name
                or "\\" in name
                or name.startswith("/")
                or any(part in {"", ".", ".."} for part in parts)
                or parts[0] != archive_root
            ):
                raise RuntimeError(f"Blender 压缩包含有不安全路径:{member.name}")
            if name in seen:
                raise RuntimeError(f"Blender 压缩包含有重复路径:{member.name}")
            seen.add(name)
            if member.isfile():
                expanded_size += member.size
                if expanded_size > _MAX_EXTRACTED_BYTES:
                    raise RuntimeError("Blender 压缩包解压大小超过限制。")
            elif member.issym() or member.islnk():
                target = member.linkname
                if not target or target.startswith("/") or "\\" in target:
                    raise RuntimeError(f"Blender 压缩包含有不安全链接:{name}")
                # 符号链接相对于所在目录,tar 硬链接相对于压缩包根目录。
                resolved = posixpath.normpath(
                    posixpath.join(posixpath.dirname(name), target)
                    if member.issym()
                    else target
                )
                if not (resolved == archive_root or resolved.startswith(archive_root + "/")):
                    raise RuntimeError(f"Blender 压缩包链接指向安装目录之外:{name}")
            elif not member.isdir():
                raise RuntimeError(f"Blender 压缩包包含不允许的特殊文件:{name}")
        # Python 3.12 的 data filter 还会检查已解压链接链,拒绝实际越界。
        bundle.extractall(destination, members=members, filter="data")
    return destination / archive_root


def ensure_blender() -> Path:
    """优先使用 BLENDER_BIN、系统 Blender,否则校验后安装官方 Linux x64 版本。"""
    configured = os.environ.get("BLENDER_BIN", "").strip()
    if configured:
        binary = Path(configured).expanduser().resolve()
        if not _valid_executable(binary):
            raise RuntimeError(f"BLENDER_BIN 不存在或不可执行:{binary}")
        return binary
    installed = shutil.which("blender")
    if installed:
        return Path(installed).resolve()

    import platform

    if platform.system() != "Linux" or platform.machine().lower() not in {"x86_64", "amd64"}:
        raise RuntimeError("自动下载只支持 Linux x64;请安装 Blender 并设置 BLENDER_BIN。")
    version = os.environ.get("BLENDER_VERSION", "5.2.2").strip()
    if not _VERSION_RE.fullmatch(version):
        raise RuntimeError("BLENDER_VERSION 必须是类似 5.2.2 的三段数字版本号。")
    cache = Path(
        os.environ.get("BLENDER_CACHE", str(Path.home() / ".cache" / "blender-render-space"))
    ).expanduser().resolve()
    installation = cache / f"blender-{version}"
    binary = installation / "blender"
    if _valid_executable(binary):
        return binary

    with _INSTALL_LOCK:
        cache.mkdir(parents=True, exist_ok=True)
        with _installation_file_lock(cache / f".install-{version}.lock"):
            if _valid_executable(binary):
                return binary
            release = ".".join(version.split(".")[:2])
            base_url = f"https://download.blender.org/release/Blender{release}/"
            archive_root = f"blender-{version}-linux-x64"
            archive_name = archive_root + ".tar.xz"
            try:
                with tempfile.TemporaryDirectory(prefix=".install-", dir=cache) as temporary:
                    staging = Path(temporary)
                    # 部分云平台无法访问 Blender 下载站。优先使用管理员随仓库
                    # 部署的安装包;仍须提供校验清单,并执行同样的安全解压检查。
                    vendor = Path(__file__).resolve().parent / "vendor"
                    bundled = vendor / archive_name
                    if bundled.is_file():
                        archive = bundled
                        if archive.stat().st_size > _MAX_ARCHIVE_BYTES:
                            raise RuntimeError("Blender 安装包超过允许大小。")
                        checksum_file = vendor / f"blender-{version}.sha256"
                        expected = _expected_digest(checksum_file, archive_name)
                    else:
                        checksum_file = staging / "checksums.sha256"
                        archive = staging / archive_name
                        _download(base_url + f"blender-{version}.sha256", checksum_file, 1024**2)
                        expected = _expected_digest(checksum_file, archive_name)
                        _download(base_url + archive_name, archive, _MAX_ARCHIVE_BYTES)
                    with archive.open("rb") as source:
                        actual = hashlib.file_digest(source, "sha256").hexdigest()
                    if actual != expected:
                        raise RuntimeError("Blender 安装包 SHA-256 校验失败,已停止安装。")
                    extracted = _safe_extract(archive, staging, archive_root)
                    if not _valid_executable(extracted / "blender"):
                        raise RuntimeError("官方安装包中未找到可执行的 Blender。")
                    # 与最终目录位于同一文件系统,rename 只会暴露完整安装。
                    previous = staging / "previous-installation"
                    if installation.exists() or installation.is_symlink():
                        installation.rename(previous)
                    try:
                        os.replace(extracted, installation)
                    except OSError:
                        if previous.exists() or previous.is_symlink():
                            previous.rename(installation)
                        raise
            except (OSError, tarfile.TarError, ValueError) as error:
                raise RuntimeError(f"Blender 自动安装失败:{error}。可设置 BLENDER_BIN 使用已有安装。") from error
    return binary


def _worker_environment(config: dict | None = None) -> dict[str, str]:
    """移除应用服务凭据,保留 CUDA、NVIDIA 和动态库搜索路径。"""
    forbidden_prefixes = (
        "HF_", "HUGGINGFACE_", "HUGGING_FACE_", "AWS_", "AZURE_", "GOOGLE_",
        "GCP_", "OPENAI_", "ANTHROPIC_", "SPACES_", "GRADIO_AUTH",
    )
    sensitive = re.compile(r"TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|API_KEY|ACCESS_KEY|PRIVATE_KEY|AUTHORIZATION")
    environment = {
        name: value
        for name, value in os.environ.items()
        if not name.upper().startswith(forbidden_prefixes) and not sensitive.search(name.upper())
    }
    # 部分 OpenMP / TBB 库默认按宿主逻辑核心数开线程,需限制过度并行。
    threads = _thread_count()
    environment["OMP_NUM_THREADS"] = str(threads)
    environment["OPENBLAS_NUM_THREADS"] = str(threads)
    environment["MKL_NUM_THREADS"] = str(threads)
    environment["PYTHONNOUSERSITE"] = "1"
    config = config or {}
    if config.get("engine", "EEVEE") == "EEVEE":
        # Use Mesa's software OpenGL only for explicitly selected CPU jobs.
        for name in ("LIBGL_ALWAYS_SOFTWARE", "GALLIUM_DRIVER", "MESA_LOADER_DRIVER_OVERRIDE",
                     "__EGL_VENDOR_LIBRARY_FILENAMES", "EGL_PLATFORM"):
            environment.pop(name, None)
        if config.get("device", "CPU") == "CPU":
            environment.update(LIBGL_ALWAYS_SOFTWARE="true", GALLIUM_DRIVER="llvmpipe",
                               EGL_PLATFORM="surfaceless", LP_NUM_THREADS=str(threads))
            mesa = Path("/usr/share/glvnd/egl_vendor.d/50_mesa.json")
            if mesa.is_file():
                environment["__EGL_VENDOR_LIBRARY_FILENAMES"] = str(mesa)
    return environment


def _thread_count() -> int:
    try:
        threads = int(os.environ.get("BLENDER_THREADS", "4"))
    except ValueError as error:
        raise RuntimeError("BLENDER_THREADS 必须为 1 至 256 之间的整数。") from error
    if not 1 <= threads <= 256:
        raise RuntimeError("BLENDER_THREADS 必须为 1 至 256 之间的整数。")
    return threads


def _stop_process_group(process: subprocess.Popen[Any]) -> None:
    """先请求终止,再结束整个进程组,包括仍在运行的后代进程。"""
    if os.name == "posix":
        try:
            os.killpg(process.pid, signal.SIGTERM)
        except ProcessLookupError:
            pass
        try:
            process.wait(timeout=3)
        except subprocess.TimeoutExpired:
            pass
        try:
            os.killpg(process.pid, signal.SIGKILL)
        except ProcessLookupError:
            pass
    else:
        # 本地 Windows 指定 BLENDER_BIN 时,同样终止进程树。
        subprocess.run(
            ["taskkill", "/PID", str(process.pid), "/T", "/F"],
            stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, timeout=10,
        )
    try:
        process.wait(timeout=5)
    except subprocess.TimeoutExpired:
        process.kill()
        process.wait(timeout=5)


def _log_tail(path: Path) -> str:
    try:
        with path.open("rb") as source:
            source.seek(0, os.SEEK_END)
            source.seek(max(0, source.tell() - 12_000))
            return source.read().decode("utf-8", errors="replace").strip()
    except OSError:
        return "日志不可读取。"


def run_task(
    task: dict,
    timeout: int,
    log_path: Path,
    cancel_path: Path | None = None,
) -> dict:
    """以可信 worker 执行任务;成功时读取 result_path,失败保留完整日志。"""
    if not isinstance(task, dict) or not task.get("result_path"):
        raise ValueError("渲染任务必须包含 result_path。")
    if not isinstance(timeout, (int, float)) or isinstance(timeout, bool) or timeout <= 0:
        raise ValueError("渲染超时必须是正数秒数。")
    if not _WORKER.is_file():
        raise RuntimeError(f"缺少可信 Blender 工作脚本:{_WORKER.name}")
    binary = ensure_blender()
    log_path = Path(log_path).resolve()
    log_path.parent.mkdir(parents=True, exist_ok=True)
    result_path = Path(task["result_path"]).resolve()
    result_path.parent.mkdir(parents=True, exist_ok=True)
    cancel_path = Path(cancel_path) if cancel_path is not None else None
    if cancel_path is not None and cancel_path.exists():
        raise RuntimeError("渲染任务已取消。")
    result_path.unlink(missing_ok=True)
    task_copy = {**task, "result_path": str(result_path)}
    descriptor, task_filename = tempfile.mkstemp(prefix=".render-task-", suffix=".json", dir=log_path.parent)
    process: subprocess.Popen[Any] | None = None
    try:
        with os.fdopen(descriptor, "w", encoding="utf-8") as output:
            json.dump(task_copy, output, ensure_ascii=False, allow_nan=False)
        command = [
            str(binary), "--background", "--factory-startup", "--disable-autoexec",
            "--threads", str(_thread_count()), "--python-exit-code", "1",
            "--gpu-backend", (task.get("config", {}).get("graphics_backend", "opengl")
                              if task.get("action") != "inspect" and
                              task.get("config", {}).get("engine") == "EEVEE" and
                              task.get("config", {}).get("device") != "CPU" else "opengl"),
            "--python", str(_WORKER), "--", task_filename,
        ]
        with log_path.open("wb") as log:
            process = subprocess.Popen(
                command,
                stdin=subprocess.DEVNULL,
                stdout=log,
                stderr=subprocess.STDOUT,
                cwd=log_path.parent,
                env=_worker_environment(task.get("config", {})),
                start_new_session=os.name == "posix",
                creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0,
            )
            started = time.monotonic()
            while process.poll() is None:
                if cancel_path is not None and cancel_path.exists():
                    _stop_process_group(process)
                    raise RuntimeError("渲染任务已取消;已完成的帧保留,可继续渲染。")
                if time.monotonic() - started >= timeout:
                    _stop_process_group(process)
                    raise TimeoutError(f"渲染任务超过 {timeout} 秒,进程组已终止;已完成的帧保留。")
                time.sleep(0.15)
        if process.returncode != 0:
            _stop_process_group(process)
            raise RuntimeError(f"Blender 执行失败(退出码 {process.returncode}):\n{_log_tail(log_path)}")
        if not result_path.is_file() or result_path.stat().st_size > 16 * 1024**2:
            raise RuntimeError(f"Blender 未生成有效结果文件:\n{_log_tail(log_path)}")
        try:
            result = json.loads(result_path.read_text(encoding="utf-8"))
        except (OSError, ValueError) as error:
            raise RuntimeError(f"Blender 返回的结果 JSON 无效:{error}") from error
        if not isinstance(result, dict):
            raise RuntimeError("Blender 结果必须是 JSON 对象。")
        return result
    finally:
        if process is not None and process.poll() is None:
            _stop_process_group(process)
        Path(task_filename).unlink(missing_ok=True)