Spaces:
Running on Zero
Running on Zero
Download blender_runtime.py from cpuai/blender-render-studio: direct link, hf CLI and curl.
- Browser
- Download file 17.5 kB
-
https://huggingface.co/spaces/cpuai/blender-render-studio/resolve/main/blender_runtime.py
- Command line
-
hf download hf://spaces/cpuai/blender-render-studio/blender_runtime.py
-
curl -L -o blender_runtime.py https://huggingface.co/spaces/cpuai/blender-render-studio/resolve/main/blender_runtime.py
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) | |
| 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) | |