blender-render-studio / blender_runtime.py
cpuai's picture
Configure GPU Eevee defaults and verify graphics backends across ZeroGPU sizes
d443689 verified
Raw History Blame Contribute Delete
17.5 kB
"""安装可信 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)