Spaces:
Running on Zero
Running on Zero
Default to CPU Eevee; retain optional GPU/Cycles and fix inherited task locks
Browse files- README.md +18 -8
- SHA256SUMS.txt +21 -18
- app.py +33 -10
- blender_runtime.py +15 -2
- docs/API.zh-CN.md +7 -2
- docs/ARCHITECTURE.zh-CN.md +7 -3
- docs/DEPLOY.zh-CN.md +8 -2
- docs/TEST_REPORT-1.1.0.zh-CN.md +9 -0
- examples/cycles-gpu.json +9 -0
- examples/frames-exr.json +4 -3
- examples/image.json +3 -2
- examples/video.json +4 -3
- packages.txt +3 -0
- render.schema.json +10 -1
- render_config.py +4 -1
- render_worker.py +37 -15
- scripts/smoke_test.py +1 -1
- service.py +41 -19
- test-artifacts/api-smoke-report.json +10 -2
- test-artifacts/cpu-render-preview.png +2 -2
- tests/test_render_modes.py +74 -0
- tests/test_service.py +1 -1
README.md
CHANGED
|
@@ -13,17 +13,26 @@ license: mit
|
|
| 13 |
|
| 14 |
# Blender Render Studio
|
| 15 |
|
| 16 |
-
上传 `.blend`、纹理/缓存 ZIP 和 JSON 渲染配置,使用
|
| 17 |
|
| 18 |
-
**
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
## 能做什么
|
| 21 |
|
| 22 |
- 上传单个已打包资源的 `.blend`,或包含工程与外部资源的 ZIP。
|
| 23 |
- JSON 设置场景、相机、视图层、分辨率、采样、降噪、透明背景、帧区间和 GPU 时间预算。
|
| 24 |
-
-
|
| 25 |
- PNG 8/16 位、OpenEXR 16/32 位帧输出;PNG 序列合成 H.264 MP4。
|
| 26 |
-
- 按批申请
|
| 27 |
- 使用同一套服务逻辑处理 UI、API 与 MCP 请求。
|
| 28 |
|
| 29 |
这是渲染服务:它保留模型、材质、灯光、动画与受支持的已烘焙缓存,但不会替用户建立动画。自动脚本、OSL、合成器与视频序列编辑器被禁用,不安装用户插件。长动画需要多次租约,**不承诺免费、不承诺无限 GPU 时间,也不保证单帧超出平台时限的大场景能够完成**。
|
|
@@ -32,9 +41,9 @@ license: mit
|
|
| 32 |
|
| 33 |
1. 新建 **Gradio SDK、Python 3.12** 的 Space,推荐设置为私有。
|
| 34 |
2. 把本项目根目录全部文件上传到 Space 根目录,保留此 README 的 YAML 配置。
|
| 35 |
-
3. 在 Space Settings 选择 **ZeroGPU** 硬件;是否可选取决于账号与平台当前资格。
|
| 36 |
4. 等待构建完成。`requirements.txt` 已包含 PyTorch;`packages.txt` 会安装 FFmpeg 与 Blender 的系统依赖。首次启动优先校验并安装 `vendor/` 中的 Blender **5.2.2**;未附带安装包时才从官方下载站下载。完整包已附带安装包,可避免下载站拒绝云平台访问的问题。
|
| 37 |
-
5. 打开「环境自检」页,先查看环境,再运行
|
| 38 |
|
| 39 |
若 ZeroGPU 自检失败,下载诊断日志;可以由你在 Space 设置中选择普通 NVIDIA GPU,并设置 `RENDER_BACKEND=gpu`。应用不会替你升级付费硬件。完整步骤、系统变量与问题排查见 [部署文档](docs/DEPLOY.zh-CN.md)。
|
| 40 |
|
|
@@ -53,7 +62,8 @@ license: mit
|
|
| 53 |
"height": 360,
|
| 54 |
"samples": 16,
|
| 55 |
"frame": 1,
|
| 56 |
-
"
|
|
|
|
| 57 |
"batch_size": 1,
|
| 58 |
"gpu_seconds": 120
|
| 59 |
}
|
|
@@ -61,7 +71,7 @@ license: mit
|
|
| 61 |
|
| 62 |
上传 JSON 文件后,它优先于编辑器内容。调整编辑器前请清空 JSON 文件上传框。若 ZIP 中有多个 `.blend`,设置 `"blend_file": "project/scene.blend"`,路径以 ZIP 解压根目录为准,使用 `/`。
|
| 63 |
|
| 64 |
-
项目提供 [图片配置](examples/image.json)、[5 秒视频配置](examples/video.json)、[EXR 帧序列配置](examples/frames-exr.json)。实际耗时由场景决定,
|
| 65 |
|
| 66 |
## Agent 一条龙调用
|
| 67 |
|
|
|
|
| 13 |
|
| 14 |
# Blender Render Studio
|
| 15 |
|
| 16 |
+
上传 `.blend`、纹理/缓存 ZIP 和 JSON 渲染配置,默认使用 CPU + Eevee 生成图片、帧序列或 MP4 视频。提供中文 Gradio 界面、API、MCP,以及可连续提交批次、断点续渲、下载结果的 Python 客户端。
|
| 17 |
|
| 18 |
+
**1.1.0:默认 CPU + Eevee,不申请 ZeroGPU。** GPU 和 Cycles 保留为手动选项。Eevee 是图形渲染引擎:CPU 模式使用 Mesa llvmpipe 软件 OpenGL,并非 Blender 原生 CPU 路径;具体速度取决于场景与 CPU,不承诺比 Cycles 快。ZeroGPU 的 CUDA 已验证可运行 Cycles,但 Eevee GPU 还需要硬件 EGL/OpenGL 驱动,不能用 CUDA 可用来证明 Eevee 可用。
|
| 19 |
+
|
| 20 |
+
| 配置 | 执行方式 |
|
| 21 |
+
| --- | --- |
|
| 22 |
+
| `engine=EEVEE, device=CPU`(默认) | CPU + Mesa 软件 OpenGL,无 GPU 租约 |
|
| 23 |
+
| `engine=CYCLES, device=CPU` | Cycles CPU,无 GPU 租约 |
|
| 24 |
+
| `engine=CYCLES, device=AUTO` | 可选 GPU;ZeroGPU 按批申请,优先 OptiX / CUDA |
|
| 25 |
+
| `engine=EEVEE, device=AUTO` | 可选图形 GPU;没有硬件图形驱动时报错,不冒充 GPU 成功 |
|
| 26 |
+
|
| 27 |
+
未指定 engine 的旧 JSON 采用新默认 Eevee;旧 CUDA / OPTIX 配置请显式增加 `engine=CYCLES`。已有任务的已保存配置不自动切换引擎,请重新创建任务,避免混合不同引擎的帧。
|
| 28 |
|
| 29 |
## 能做什么
|
| 30 |
|
| 31 |
- 上传单个已打包资源的 `.blend`,或包含工程与外部资源的 ZIP。
|
| 32 |
- JSON 设置场景、相机、视图层、分辨率、采样、降噪、透明背景、帧区间和 GPU 时间预算。
|
| 33 |
+
- Eevee / Cycles 与 CPU / GPU 可选;任务 JSON 的 `device=CPU` 始终直接在 CPU 执行。
|
| 34 |
- PNG 8/16 位、OpenEXR 16/32 位帧输出;PNG 序列合成 H.264 MP4。
|
| 35 |
+
- CPU 直接渲染,GPU 才按批申请,保存完成帧;取消、恢复、导出部分结果与日志。
|
| 36 |
- 使用同一套服务逻辑处理 UI、API 与 MCP 请求。
|
| 37 |
|
| 38 |
这是渲染服务:它保留模型、材质、灯光、动画与受支持的已烘焙缓存,但不会替用户建立动画。自动脚本、OSL、合成器与视频序列编辑器被禁用,不安装用户插件。长动画需要多次租约,**不承诺免费、不承诺无限 GPU 时间,也不保证单帧超出平台时限的大场景能够完成**。
|
|
|
|
| 41 |
|
| 42 |
1. 新建 **Gradio SDK、Python 3.12** 的 Space,推荐设置为私有。
|
| 43 |
2. 把本项目根目录全部文件上传到 Space 根目录,保留此 README 的 YAML 配置。
|
| 44 |
+
3. 在 Space Settings 选择 **ZeroGPU** 硬件;是否可选取决于账号与平台当前资格。保留 `RENDER_BACKEND=zerogpu` 即可提供可选 GPU;默认任务仍使用 CPU。也可在 CPU Space 上使用默认模式。
|
| 45 |
4. 等待构建完成。`requirements.txt` 已包含 PyTorch;`packages.txt` 会安装 FFmpeg 与 Blender 的系统依赖。首次启动优先校验并安装 `vendor/` 中的 Blender **5.2.2**;未附带安装包时才从官方下载站下载。完整包已附带安装包,可避免下载站拒绝云平台访问的问题。
|
| 46 |
+
5. 打开「环境自检」页,先查看环境,先运行 CPU / Eevee 自检;使用 GPU 前再运行 GPU / Cycles 自检。只有返回 `ok=true` 且 `gpu_verified=true` 才开始正式 GPU 任务。
|
| 47 |
|
| 48 |
若 ZeroGPU 自检失败,下载诊断日志;可以由你在 Space 设置中选择普通 NVIDIA GPU,并设置 `RENDER_BACKEND=gpu`。应用不会替你升级付费硬件。完整步骤、系统变量与问题排查见 [部署文档](docs/DEPLOY.zh-CN.md)。
|
| 49 |
|
|
|
|
| 62 |
"height": 360,
|
| 63 |
"samples": 16,
|
| 64 |
"frame": 1,
|
| 65 |
+
"engine": "EEVEE",
|
| 66 |
+
"device": "CPU",
|
| 67 |
"batch_size": 1,
|
| 68 |
"gpu_seconds": 120
|
| 69 |
}
|
|
|
|
| 71 |
|
| 72 |
上传 JSON 文件后,它优先于编辑器内容。调整编辑器前请清空 JSON 文件上传框。若 ZIP 中有多个 `.blend`,设置 `"blend_file": "project/scene.blend"`,路径以 ZIP 解压根目录为准,使用 `/`。
|
| 73 |
|
| 74 |
+
项目提供 [图片配置](examples/image.json)、[5 秒视频配置](examples/video.json)、[EXR 帧序列配置](examples/frames-exr.json)。实际耗时由场景决定,`gpu_seconds` 仅在 GPU 模式使用,CPU 任务使用 `cpu_timeout`。
|
| 75 |
|
| 76 |
## Agent 一条龙调用
|
| 77 |
|
SHA256SUMS.txt
CHANGED
|
@@ -1,38 +1,41 @@
|
|
| 1 |
01ca9723815bf82ce4f5a5ea72b3ffca1e3f0861ca031f0611a9fbfb028352d8 .gitignore
|
| 2 |
9859366fd6a1e412cde3018acec7a11746d71282e23a8e0b51f9ae83a153b02c LICENSE
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
|
|
|
| 9 |
ca04fb9e7d16e031c7b10ff1dd962839a70118762f086da82722b674bafa8c34 docs/TEST_REPORT.zh-CN.md
|
| 10 |
15c1033cf2053ed563dda8f8d550bb4c654fa42498c0b18e3f9dd56ea1ed81c8 examples/client_render.py
|
|
|
|
| 11 |
5526526b59e34340d00fd0f50c9eb154b6cbc2a20966e978676016c76246b299 examples/demo.blend
|
| 12 |
-
|
| 13 |
-
|
| 14 |
9c31eed31fe6dbf766c61a8d2a496e10e7217dd6484ea8110664df9876285078 examples/mcp-client.json
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
5e80794bcc6d2549958645e311fe2e150307ca42d1a05b154959c044697327ad requirements-dev.txt
|
| 21 |
87a9e0631bcf876ec66709de42468e22aad8d55ec7eacfcecc6cb4042b0b9685 requirements.txt
|
| 22 |
65e361844de06033efda047b4a4092f6d073f03e5bfef61af95cf0af66a1c9a0 scripts/create_demo.py
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
37c0bcbd782040c859387845cb02eefc787628b70da1c591fbcbf3866926d06f test-artifacts/cpu-animation-preview.mp4
|
| 27 |
-
|
| 28 |
c36a41702e9d6fa2c080926aaeb1a54028fe845fd987ceee01b15dec44c9c1e3 test-artifacts/exr-smoke-report.json
|
| 29 |
f780ddecea166cabf5b6ee4ce8abe1d94f9ff24abdf9c2c7b27d3b04722be6aa test-artifacts/video-smoke-report.json
|
| 30 |
055a91885259120961a179671ef1b66b8ccc3da372796211e88fbdeb103dfb79 test-artifacts/zerogpu-animation-preview.mp4
|
| 31 |
7e10fac1dbc5b431996c178b76f34e09d96b0991a255a333b7b1b288a18256d7 test-artifacts/zerogpu-render-preview.png
|
| 32 |
2c0b188931be19ea4d449b4b6dc0a8d6410b5035b1a0a7761b3cea1c9dd13f67 test-artifacts/zerogpu-smoke-report.json
|
| 33 |
6ec47b34933b82755ad54217aa0fd2a9ac9d9636c2f1c87a42d3d735ea2d6cfa tests/test_config_uploads.py
|
|
|
|
| 34 |
b4311978dd179edbc7a92bc442b83b736edf2c746ebccbc34ce81e862909b4d8 tests/test_runtime.py
|
| 35 |
-
|
| 36 |
4fb5d2d55725d9adb5f8c58be6192cde4d825f2f31c1b698458eb39f8add3a37 uploads.py
|
| 37 |
988e0c9b9146513f69c01f70dd55fac73a2fd0e749c66ba440de7fdc07a32f6b vendor/README.md
|
| 38 |
84098912789dc450e95697c4184fb8a90acbe5111c2ba4aede3fecb57806a168 vendor/blender-5.2.2-linux-x64.tar.xz
|
|
|
|
| 1 |
01ca9723815bf82ce4f5a5ea72b3ffca1e3f0861ca031f0611a9fbfb028352d8 .gitignore
|
| 2 |
9859366fd6a1e412cde3018acec7a11746d71282e23a8e0b51f9ae83a153b02c LICENSE
|
| 3 |
+
d890598d803cdf4eb8d0cfedb65e8f24d0e57a9d30ee5ddf50caa9ac36a1258f README.md
|
| 4 |
+
6a1b5671dc8cbb821c53d59ec87bdef44cf27043c29f4d12abd02f4d4600949c app.py
|
| 5 |
+
8a66bdfd18fc776fd8e3796321c55d74c5a653ab9b8ad6db5c72d5d1e46e4f14 blender_runtime.py
|
| 6 |
+
cc802ca4d81169e26cbb8e3a5f99f85f27cadbe42878ecf75065fe8d4a7d2dad docs/API.zh-CN.md
|
| 7 |
+
8055fec30c5327cc6473cc2dd72d8f76ff63d4fdddf726a2a03d048fc732bb1e docs/ARCHITECTURE.zh-CN.md
|
| 8 |
+
4d7f45c20a39dc86820ddc379f2137efeb9017ada58e33387d288d9fc1a1406c docs/DEPLOY.zh-CN.md
|
| 9 |
+
e9fc2087b746d6f86cf5b7f6f279203cc9297379c590e948f660109809f7d21a docs/TEST_REPORT-1.1.0.zh-CN.md
|
| 10 |
ca04fb9e7d16e031c7b10ff1dd962839a70118762f086da82722b674bafa8c34 docs/TEST_REPORT.zh-CN.md
|
| 11 |
15c1033cf2053ed563dda8f8d550bb4c654fa42498c0b18e3f9dd56ea1ed81c8 examples/client_render.py
|
| 12 |
+
666283a6b6eec04a781fdb3da2440fca82d21c2a051b82e5022c08158684db59 examples/cycles-gpu.json
|
| 13 |
5526526b59e34340d00fd0f50c9eb154b6cbc2a20966e978676016c76246b299 examples/demo.blend
|
| 14 |
+
30eb328bebb04efac0e992a459b008d04c1d55d3a6c1c1f8636880fcf3fbfec7 examples/frames-exr.json
|
| 15 |
+
638f0ac4677a145ba396748dedebdc164d7b39bcb7ffa67790700722ef5e3e0e examples/image.json
|
| 16 |
9c31eed31fe6dbf766c61a8d2a496e10e7217dd6484ea8110664df9876285078 examples/mcp-client.json
|
| 17 |
+
e4dd8b9a4848b80580955a99ffbae6457eb6c1c30552ac0a6ab20a5995c36e75 examples/video.json
|
| 18 |
+
14e5fded3a6736acc30d48afc3c7c69cce7358ec9790ff393c69b451f0d679d7 packages.txt
|
| 19 |
+
4e40dd38f6ea857f170064059fdff1092f54152eaed02d115c0c9d9cc7ed1e7d render.schema.json
|
| 20 |
+
11b88858aa4caa6b7ab3d0028964c3350a578ae15b6e46664510f6959f8ef2e3 render_config.py
|
| 21 |
+
f84bddf01a48007804a9ebb92e2ac55131a73ba169985c1a202dd500d4f958c9 render_worker.py
|
| 22 |
5e80794bcc6d2549958645e311fe2e150307ca42d1a05b154959c044697327ad requirements-dev.txt
|
| 23 |
87a9e0631bcf876ec66709de42468e22aad8d55ec7eacfcecc6cb4042b0b9685 requirements.txt
|
| 24 |
65e361844de06033efda047b4a4092f6d073f03e5bfef61af95cf0af66a1c9a0 scripts/create_demo.py
|
| 25 |
+
8745e606faf94fe48dd46d887262f7a7319798a5672d03fdc1aaf14beb6faf9f scripts/smoke_test.py
|
| 26 |
+
a81ee88e76013334db37af6e2b2488623d4c0366f0ae524f40055517c1c2aaa8 service.py
|
| 27 |
+
b7917759d8864af2578fbae948404f9343c0c89a0282284bc251db9322165298 test-artifacts/api-smoke-report.json
|
| 28 |
37c0bcbd782040c859387845cb02eefc787628b70da1c591fbcbf3866926d06f test-artifacts/cpu-animation-preview.mp4
|
| 29 |
+
fde62827730295cf0435bc847b9908787c1df36e78ed85394d78afd3ffc6cc0e test-artifacts/cpu-render-preview.png
|
| 30 |
c36a41702e9d6fa2c080926aaeb1a54028fe845fd987ceee01b15dec44c9c1e3 test-artifacts/exr-smoke-report.json
|
| 31 |
f780ddecea166cabf5b6ee4ce8abe1d94f9ff24abdf9c2c7b27d3b04722be6aa test-artifacts/video-smoke-report.json
|
| 32 |
055a91885259120961a179671ef1b66b8ccc3da372796211e88fbdeb103dfb79 test-artifacts/zerogpu-animation-preview.mp4
|
| 33 |
7e10fac1dbc5b431996c178b76f34e09d96b0991a255a333b7b1b288a18256d7 test-artifacts/zerogpu-render-preview.png
|
| 34 |
2c0b188931be19ea4d449b4b6dc0a8d6410b5035b1a0a7761b3cea1c9dd13f67 test-artifacts/zerogpu-smoke-report.json
|
| 35 |
6ec47b34933b82755ad54217aa0fd2a9ac9d9636c2f1c87a42d3d735ea2d6cfa tests/test_config_uploads.py
|
| 36 |
+
ea4ab35f50549f0bd55dafa7a0b4d8945288c2262e5d2697f669600012b8a8b3 tests/test_render_modes.py
|
| 37 |
b4311978dd179edbc7a92bc442b83b736edf2c746ebccbc34ce81e862909b4d8 tests/test_runtime.py
|
| 38 |
+
1ad4758353682535e826f429f39a9990f87e3afc180a5cae93fb1273c5a5d370 tests/test_service.py
|
| 39 |
4fb5d2d55725d9adb5f8c58be6192cde4d825f2f31c1b698458eb39f8add3a37 uploads.py
|
| 40 |
988e0c9b9146513f69c01f70dd55fac73a2fd0e749c66ba440de7fdc07a32f6b vendor/README.md
|
| 41 |
84098912789dc450e95697c4184fb8a90acbe5111c2ba4aede3fecb57806a168 vendor/blender-5.2.2-linux-x64.tar.xz
|
app.py
CHANGED
|
@@ -15,7 +15,7 @@ import gradio as gr
|
|
| 15 |
|
| 16 |
import service
|
| 17 |
from blender_runtime import ensure_blender
|
| 18 |
-
from render_config import RenderConfig
|
| 19 |
|
| 20 |
|
| 21 |
LOGGER = logging.getLogger(__name__)
|
|
@@ -51,7 +51,8 @@ HERO = """
|
|
| 51 |
<p>上传 Blender 工程与资源,用 JSON 驱动图片或动画渲染。<br>
|
| 52 |
按批次保存帧,通过 API 与 MCP 接入你的创作 Agent。</p>
|
| 53 |
<div class="pills">
|
| 54 |
-
<span class="pill">
|
|
|
|
| 55 |
<span class="pill">逐批续渲</span>
|
| 56 |
<span class="pill">图片 / 视频</span>
|
| 57 |
<span class="pill">API + MCP</span>
|
|
@@ -140,7 +141,7 @@ def render_chunk(job_id: str) -> dict[str, Any]:
|
|
| 140 |
|
| 141 |
|
| 142 |
def _render_remaining(job_id: str) -> Iterator[dict[str, Any]]:
|
| 143 |
-
"""界面专用连续模式:
|
| 144 |
job_id = _job_id(job_id)
|
| 145 |
current = _invoke(service.job_status, job_id)
|
| 146 |
yield current
|
|
@@ -227,6 +228,11 @@ def finalize_job(
|
|
| 227 |
return _invoke(service.finalize_job, _job_id(job_id))
|
| 228 |
|
| 229 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
def gpu_self_test() -> tuple[dict[str, Any], list[str], str | None]:
|
| 231 |
"""Test actual Blender GPU rendering with a small built-in scene.
|
| 232 |
|
|
@@ -244,15 +250,21 @@ def _preset(mode: str) -> str:
|
|
| 244 |
return json.dumps(data, indent=2, ensure_ascii=False)
|
| 245 |
|
| 246 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
def build_app() -> gr.Blocks:
|
| 248 |
"""构建界面;导入本模块不会下载 Blender,也不会申请 GPU。"""
|
| 249 |
with gr.Blocks(title="Blender Render Space", delete_cache=(3600, 86400)) as demo:
|
| 250 |
gr.HTML(HERO)
|
| 251 |
gr.Markdown(
|
| 252 |
-
"**
|
| 253 |
-
"
|
| 254 |
-
"GPU
|
| 255 |
-
"长动画由多个有时限的批次完成,单帧仍需在单次租约内完成。",
|
| 256 |
elem_classes="workflow-note",
|
| 257 |
)
|
| 258 |
|
|
@@ -295,6 +307,11 @@ def build_app() -> gr.Blocks:
|
|
| 295 |
"`batch_size` 控制每次提交的帧数;建议先用低分辨率、"
|
| 296 |
"少量采样验证,再提高画质。"
|
| 297 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
prepare = gr.Button("准备渲染任务", variant="primary", size="lg")
|
| 299 |
|
| 300 |
with gr.Column(scale=6, min_width=320):
|
|
@@ -321,7 +338,7 @@ def build_app() -> gr.Blocks:
|
|
| 321 |
gr.Markdown(
|
| 322 |
"每次点击「渲染下一批」处理一个批次;剩余帧可继续提交。"
|
| 323 |
"「连续渲染剩余帧」会逐批提交,遇到错误或配额不足立即结束,"
|
| 324 |
-
"不会自动重试。**
|
| 325 |
"API / Agent 也可按状态自动循环。"
|
| 326 |
"停止后保留已完成帧;点击「恢复任务」后再提交渲染。"
|
| 327 |
)
|
|
@@ -351,11 +368,12 @@ def build_app() -> gr.Blocks:
|
|
| 351 |
"自检失败时,请下载日志检查原因,或选择常驻 GPU Space。\n\n"
|
| 352 |
"后端由 Space 环境变量 `RENDER_BACKEND` 选择:"
|
| 353 |
"`auto`、`zerogpu`、`gpu` 或 `cpu`。"
|
| 354 |
-
"CPU 模式
|
| 355 |
)
|
| 356 |
with gr.Row():
|
| 357 |
env_button = gr.Button("读取环境")
|
| 358 |
-
|
|
|
|
| 359 |
env_output = gr.JSON(label="环境信息")
|
| 360 |
with gr.Row():
|
| 361 |
with gr.Column():
|
|
@@ -375,6 +393,7 @@ def build_app() -> gr.Blocks:
|
|
| 375 |
"| API | 用途 |\n| --- | --- |\n"
|
| 376 |
"| `environment_info` | 检查后端与安装信息 |\n"
|
| 377 |
"| `config_schema` | 获取完整 JSON Schema |\n"
|
|
|
|
| 378 |
"| `gpu_self_test` | 验证 Blender 实际 GPU 渲染 |\n"
|
| 379 |
"| `prepare_job` | 上传工程和配置,创建任务 |\n"
|
| 380 |
"| `inspect_job` | 检查场景、相机与资源 |\n"
|
|
@@ -427,6 +446,10 @@ def build_app() -> gr.Blocks:
|
|
| 427 |
)
|
| 428 |
env_button.click(environment_info, None, env_output, api_name="environment_info", queue=False)
|
| 429 |
schema_button.click(config_schema, None, schema_output, api_name="config_schema", queue=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 430 |
probe_button.click(
|
| 431 |
gpu_self_test,
|
| 432 |
None,
|
|
|
|
| 15 |
|
| 16 |
import service
|
| 17 |
from blender_runtime import ensure_blender
|
| 18 |
+
from render_config import RenderConfig, parse_config
|
| 19 |
|
| 20 |
|
| 21 |
LOGGER = logging.getLogger(__name__)
|
|
|
|
| 51 |
<p>上传 Blender 工程与资源,用 JSON 驱动图片或动画渲染。<br>
|
| 52 |
按批次保存帧,通过 API 与 MCP 接入你的创作 Agent。</p>
|
| 53 |
<div class="pills">
|
| 54 |
+
<span class="pill">默认 CPU + Eevee</span>
|
| 55 |
+
<span class="pill">GPU / Cycles 可选</span>
|
| 56 |
<span class="pill">逐批续渲</span>
|
| 57 |
<span class="pill">图片 / 视频</span>
|
| 58 |
<span class="pill">API + MCP</span>
|
|
|
|
| 141 |
|
| 142 |
|
| 143 |
def _render_remaining(job_id: str) -> Iterator[dict[str, Any]]:
|
| 144 |
+
"""界面专用连续模式:按所选后端逐批渲染,错误或取消后立即结束。"""
|
| 145 |
job_id = _job_id(job_id)
|
| 146 |
current = _invoke(service.job_status, job_id)
|
| 147 |
yield current
|
|
|
|
| 228 |
return _invoke(service.finalize_job, _job_id(job_id))
|
| 229 |
|
| 230 |
|
| 231 |
+
def cpu_self_test() -> tuple[dict[str, Any], list[str], str | None]:
|
| 232 |
+
"""Verify default CPU Eevee rendering without allocating a GPU."""
|
| 233 |
+
return _invoke(service.cpu_self_test)
|
| 234 |
+
|
| 235 |
+
|
| 236 |
def gpu_self_test() -> tuple[dict[str, Any], list[str], str | None]:
|
| 237 |
"""Test actual Blender GPU rendering with a small built-in scene.
|
| 238 |
|
|
|
|
| 250 |
return json.dumps(data, indent=2, ensure_ascii=False)
|
| 251 |
|
| 252 |
|
| 253 |
+
def _render_options(config_json: str, engine: str, device: str) -> str:
|
| 254 |
+
# Update only render choices; retain camera, animation and quality settings.
|
| 255 |
+
data = json.loads(config_json or "{}")
|
| 256 |
+
data.update(engine=engine, device=device)
|
| 257 |
+
return parse_config(json.dumps(data)).model_dump_json(indent=2)
|
| 258 |
+
|
| 259 |
+
|
| 260 |
def build_app() -> gr.Blocks:
|
| 261 |
"""构建界面;导入本模块不会下载 Blender,也不会申请 GPU。"""
|
| 262 |
with gr.Blocks(title="Blender Render Space", delete_cache=(3600, 86400)) as demo:
|
| 263 |
gr.HTML(HERO)
|
| 264 |
gr.Markdown(
|
| 265 |
+
"**默认 CPU + Eevee**,不申请 ZeroGPU。Eevee 在 CPU 上通过软件图形驱动渲染。"
|
| 266 |
+
"需要光线追踪时可选 Cycles;GPU 为手动选项。"
|
| 267 |
+
"ZeroGPU 的 GPU 选项推荐搭配 Cycles,Eevee GPU 需要可用的硬件图形驱动。",
|
|
|
|
| 268 |
elem_classes="workflow-note",
|
| 269 |
)
|
| 270 |
|
|
|
|
| 307 |
"`batch_size` 控制每次提交的帧数;建议先用低分辨率、"
|
| 308 |
"少量采样验证,再提高画质。"
|
| 309 |
)
|
| 310 |
+
with gr.Row():
|
| 311 |
+
engine_choice = gr.Dropdown([("Eevee(默认)", "EEVEE"), ("Cycles", "CYCLES")], value="EEVEE", label="渲染引擎")
|
| 312 |
+
device_choice = gr.Dropdown([("CPU(默认)", "CPU"), ("GPU(自动选择)", "AUTO")], value="CPU", label="计算设备")
|
| 313 |
+
apply_options = gr.Button("将引擎与设备应用到 JSON", size="sm")
|
| 314 |
+
gr.Markdown("选择后点击应用;创建任务以 JSON 为准。上传 JSON 文件时,请直接修改文件中的 engine / device。")
|
| 315 |
prepare = gr.Button("准备渲染任务", variant="primary", size="lg")
|
| 316 |
|
| 317 |
with gr.Column(scale=6, min_width=320):
|
|
|
|
| 338 |
gr.Markdown(
|
| 339 |
"每次点击「渲染下一批」处理一个批次;剩余帧可继续提交。"
|
| 340 |
"「连续渲染剩余帧」会逐批提交,遇到错误或配额不足立即结束,"
|
| 341 |
+
"不会自动重试。**仅选择 GPU 时消耗 GPU 配额;可随时点击「停止任务」。**"
|
| 342 |
"API / Agent 也可按状态自动循环。"
|
| 343 |
"停止后保留已完成帧;点击「恢复任务」后再提交渲染。"
|
| 344 |
)
|
|
|
|
| 368 |
"自检失败时,请下载日志检查原因,或选择常驻 GPU Space。\n\n"
|
| 369 |
"后端由 Space 环境变量 `RENDER_BACKEND` 选择:"
|
| 370 |
"`auto`、`zerogpu`、`gpu` 或 `cpu`。"
|
| 371 |
+
"CPU + Eevee 是默认工作模式。任务 JSON 的 device=CPU 始终绕过 GPU 租约;GPU 不进行静默 CPU 回退。"
|
| 372 |
)
|
| 373 |
with gr.Row():
|
| 374 |
env_button = gr.Button("读取环境")
|
| 375 |
+
cpu_probe_button = gr.Button("运行 CPU / Eevee 自检", variant="primary")
|
| 376 |
+
probe_button = gr.Button("运行 GPU / Cycles 自检")
|
| 377 |
env_output = gr.JSON(label="环境信息")
|
| 378 |
with gr.Row():
|
| 379 |
with gr.Column():
|
|
|
|
| 393 |
"| API | 用途 |\n| --- | --- |\n"
|
| 394 |
"| `environment_info` | 检查后端与安装信息 |\n"
|
| 395 |
"| `config_schema` | 获取完整 JSON Schema |\n"
|
| 396 |
+
"| `cpu_self_test` | 验证默认 CPU Eevee 渲染 |\n"
|
| 397 |
"| `gpu_self_test` | 验证 Blender 实际 GPU 渲染 |\n"
|
| 398 |
"| `prepare_job` | 上传工程和配置,创建任务 |\n"
|
| 399 |
"| `inspect_job` | 检查场景、相机与资源 |\n"
|
|
|
|
| 446 |
)
|
| 447 |
env_button.click(environment_info, None, env_output, api_name="environment_info", queue=False)
|
| 448 |
schema_button.click(config_schema, None, schema_output, api_name="config_schema", queue=False)
|
| 449 |
+
apply_options.click(_render_options, [config_editor, engine_choice, device_choice], config_editor,
|
| 450 |
+
queue=False, api_visibility="private")
|
| 451 |
+
cpu_probe_button.click(cpu_self_test, None, [probe_output, probe_files, probe_image],
|
| 452 |
+
api_name="cpu_self_test", **blender_queue)
|
| 453 |
probe_button.click(
|
| 454 |
gpu_self_test,
|
| 455 |
None,
|
blender_runtime.py
CHANGED
|
@@ -213,7 +213,7 @@ def ensure_blender() -> Path:
|
|
| 213 |
return binary
|
| 214 |
|
| 215 |
|
| 216 |
-
def _worker_environment() -> dict[str, str]:
|
| 217 |
"""移除应用服务凭据,保留 CUDA、NVIDIA 和动态库搜索路径。"""
|
| 218 |
forbidden_prefixes = (
|
| 219 |
"HF_", "HUGGINGFACE_", "HUGGING_FACE_", "AWS_", "AZURE_", "GOOGLE_",
|
|
@@ -231,6 +231,18 @@ def _worker_environment() -> dict[str, str]:
|
|
| 231 |
environment["OPENBLAS_NUM_THREADS"] = str(threads)
|
| 232 |
environment["MKL_NUM_THREADS"] = str(threads)
|
| 233 |
environment["PYTHONNOUSERSITE"] = "1"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
return environment
|
| 235 |
|
| 236 |
|
|
@@ -313,6 +325,7 @@ def run_task(
|
|
| 313 |
command = [
|
| 314 |
str(binary), "--background", "--factory-startup", "--disable-autoexec",
|
| 315 |
"--threads", str(_thread_count()), "--python-exit-code", "1",
|
|
|
|
| 316 |
"--python", str(_WORKER), "--", task_filename,
|
| 317 |
]
|
| 318 |
with log_path.open("wb") as log:
|
|
@@ -322,7 +335,7 @@ def run_task(
|
|
| 322 |
stdout=log,
|
| 323 |
stderr=subprocess.STDOUT,
|
| 324 |
cwd=log_path.parent,
|
| 325 |
-
env=_worker_environment(),
|
| 326 |
start_new_session=os.name == "posix",
|
| 327 |
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0,
|
| 328 |
)
|
|
|
|
| 213 |
return binary
|
| 214 |
|
| 215 |
|
| 216 |
+
def _worker_environment(config: dict | None = None) -> dict[str, str]:
|
| 217 |
"""移除应用服务凭据,保留 CUDA、NVIDIA 和动态库搜索路径。"""
|
| 218 |
forbidden_prefixes = (
|
| 219 |
"HF_", "HUGGINGFACE_", "HUGGING_FACE_", "AWS_", "AZURE_", "GOOGLE_",
|
|
|
|
| 231 |
environment["OPENBLAS_NUM_THREADS"] = str(threads)
|
| 232 |
environment["MKL_NUM_THREADS"] = str(threads)
|
| 233 |
environment["PYTHONNOUSERSITE"] = "1"
|
| 234 |
+
config = config or {}
|
| 235 |
+
if config.get("engine", "EEVEE") == "EEVEE":
|
| 236 |
+
# Use Mesa's software OpenGL only for explicitly selected CPU jobs.
|
| 237 |
+
for name in ("LIBGL_ALWAYS_SOFTWARE", "GALLIUM_DRIVER", "MESA_LOADER_DRIVER_OVERRIDE",
|
| 238 |
+
"__EGL_VENDOR_LIBRARY_FILENAMES", "EGL_PLATFORM"):
|
| 239 |
+
environment.pop(name, None)
|
| 240 |
+
if config.get("device", "CPU") == "CPU":
|
| 241 |
+
environment.update(LIBGL_ALWAYS_SOFTWARE="true", GALLIUM_DRIVER="llvmpipe",
|
| 242 |
+
EGL_PLATFORM="surfaceless", LP_NUM_THREADS=str(threads))
|
| 243 |
+
mesa = Path("/usr/share/glvnd/egl_vendor.d/50_mesa.json")
|
| 244 |
+
if mesa.is_file():
|
| 245 |
+
environment["__EGL_VENDOR_LIBRARY_FILENAMES"] = str(mesa)
|
| 246 |
return environment
|
| 247 |
|
| 248 |
|
|
|
|
| 325 |
command = [
|
| 326 |
str(binary), "--background", "--factory-startup", "--disable-autoexec",
|
| 327 |
"--threads", str(_thread_count()), "--python-exit-code", "1",
|
| 328 |
+
"--gpu-backend", "opengl",
|
| 329 |
"--python", str(_WORKER), "--", task_filename,
|
| 330 |
]
|
| 331 |
with log_path.open("wb") as log:
|
|
|
|
| 335 |
stdout=log,
|
| 336 |
stderr=subprocess.STDOUT,
|
| 337 |
cwd=log_path.parent,
|
| 338 |
+
env=_worker_environment(task.get("config", {})),
|
| 339 |
start_new_session=os.name == "posix",
|
| 340 |
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0,
|
| 341 |
)
|
docs/API.zh-CN.md
CHANGED
|
@@ -141,7 +141,8 @@ job_id, status = client.predict(
|
|
| 141 |
| `scene` / `camera` / `view_layer` | 名称或 null | 指定 Blender 数据块;默认沿用场景设置 |
|
| 142 |
| `width` / `height` | 16–8192 | 基础分辨率,受像素总量上限限制 |
|
| 143 |
| `resolution_percentage` | 1–100 | 实际输出缩放比例 |
|
| 144 |
-
| `
|
|
|
|
| 145 |
| `denoise` | true / false | 降噪 |
|
| 146 |
| `adaptive_threshold` | 0.0–1.0 | 自适应采样阈值 |
|
| 147 |
| `frame` | 正整数 | 单图帧号 |
|
|
@@ -150,7 +151,7 @@ job_id, status = client.predict(
|
|
| 150 |
| `format` | `PNG` / `OPEN_EXR` | 视频模式只接受 PNG |
|
| 151 |
| `color_depth` | 8 / 16 / 32 | PNG:8/16;EXR:16/32 |
|
| 152 |
| `transparent` | true / false | 透明背景;MP4 不支持 |
|
| 153 |
-
| `device` | `AUTO` / `CUDA` / `OPTIX` / `CPU` |
|
| 154 |
| `batch_size` | 1–64 | 每次请求最多渲染帧数 |
|
| 155 |
| `gpu_seconds` | 10–600 | ZeroGPU 请求预算;平台可能另有限制,不是时长保证 |
|
| 156 |
| `cpu_timeout` | 10–7200 | 普通 GPU/CPU 子进程与编码超时 |
|
|
@@ -190,3 +191,7 @@ HF 也提供 Hub MCP 集成;是否能直接添加某个私有 Space、如何
|
|
| 190 |
## 推荐 Agent 策略
|
| 191 |
|
| 192 |
先读取 `environment_info` 与 `config_schema`;首次部署调用 `gpu_self_test`。准备后立刻保存 ID,检查场景相机和缺失资产,再串行调用 `render_chunk`,每次都判断错误和完成帧增量。全部完成后 `finalize_job` 并核验输出。出现配额、超时或兼容性失败时停止自动重试、取回部分输出和日志,明确报告原因;修复后用相同 ID 恢复,若修改工程或配置则新建任务。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
| `scene` / `camera` / `view_layer` | 名称或 null | 指定 Blender 数据块;默认沿用场景设置 |
|
| 142 |
| `width` / `height` | 16–8192 | 基础分辨率,受像素总量上限限制 |
|
| 143 |
| `resolution_percentage` | 1–100 | 实际输出缩放比例 |
|
| 144 |
+
| `engine` | `EEVEE` / `CYCLES` | 默认 Eevee |
|
| 145 |
+
| `samples` | 1–4096 | Eevee 渲染采样数或 Cycles 最大采样数 |
|
| 146 |
| `denoise` | true / false | 降噪 |
|
| 147 |
| `adaptive_threshold` | 0.0–1.0 | 自适应采样阈值 |
|
| 148 |
| `frame` | 正整数 | 单图帧号 |
|
|
|
|
| 151 |
| `format` | `PNG` / `OPEN_EXR` | 视频模式只接受 PNG |
|
| 152 |
| `color_depth` | 8 / 16 / 32 | PNG:8/16;EXR:16/32 |
|
| 153 |
| `transparent` | true / false | 透明背景;MP4 不支持 |
|
| 154 |
+
| `device` | `AUTO` / `CUDA` / `OPTIX` / `CPU` | 默认 CPU;无需管理员切换。AUTO 为显式 GPU,CUDA/OPTIX 仅适用 Cycles |
|
| 155 |
| `batch_size` | 1–64 | 每次请求最多渲染帧数 |
|
| 156 |
| `gpu_seconds` | 10–600 | ZeroGPU 请求预算;平台可能另有限制,不是时长保证 |
|
| 157 |
| `cpu_timeout` | 10–7200 | 普通 GPU/CPU 子进程与编码超时 |
|
|
|
|
| 191 |
## 推荐 Agent 策略
|
| 192 |
|
| 193 |
先读取 `environment_info` 与 `config_schema`;首次部署调用 `gpu_self_test`。准备后立刻保存 ID,检查场景相机和缺失资产,再串行调用 `render_chunk`,每次都判断错误和完成帧增量。全部完成后 `finalize_job` 并核验输出。出现配额、超时或兼容性失败时停止自动重试、取回部分输出和日志,明确报告原因;修复后用相同 ID 恢复,若修改工程或配置则新建任务。
|
| 194 |
+
|
| 195 |
+
## 1.1.0 默认模式
|
| 196 |
+
|
| 197 |
+
空配置 `{}` 选择 `engine=EEVEE, device=CPU`,使用 Mesa llvmpipe 软件 OpenGL,不申请 GPU。`cpu_self_test` 无参数,返回真实 CPU Eevee 自检结果、日志与图片;`gpu_self_test` 明确测试 Cycles GPU。ZeroGPU 不保证提供 Eevee 所需硬件 EGL/OpenGL;Eevee GPU 检测到软件驱动会报错。已有旧任务按 Cycles 续渲,新任务使用新默认。
|
docs/ARCHITECTURE.zh-CN.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
| 1 |
# 架构与实施规划
|
| 2 |
|
| 3 |
-
版本:1.
|
| 4 |
|
| 5 |
## 可行性结论与技术选择
|
| 6 |
|
| 7 |
-
ZeroGPU 通过 `spaces.GPU` 为函数申请有时间限制的 GPU;官方重点支持 Gradio/PyTorch。Blender Cycles 是外部原生程序,因此平台兼容性是必须实测的前提,而不是装饰器能自动保证的事情。当前采取「租约内启动 Blender 子进程 → 检测 Cycles 设备 → 实际渲染 → 读取报告」路径。平台如果禁止或不支持这条路径,就明确失败并保留日志。普通 NVIDIA GPU Space 作为显式备选,CPU
|
| 8 |
|
| 9 |
采用官方 Blender Linux x64 分发包而不是在 Python 中嵌入 `bpy`:保持 Blender 自己的 Python、Cycles 与打包库版本一致,同时便于超时后结束进程树。服务端 Python 3.12、Gradio 6.28.0、spaces 0.51.3;PyTorch 固定为官方支持的 2.8.0,用于 ZeroGPU 平台运行时,真正的图像生成仍由 Blender 完成。[spaces 发布记录](https://pypi.org/project/spaces/0.51.3/)、[Gradio 发布记录](https://pypi.org/project/gradio/6.28.0/)
|
| 10 |
|
|
@@ -32,7 +32,7 @@ flowchart TD
|
|
| 32 |
B --> C["场景、相机与资源检查"]
|
| 33 |
C --> D{"执行后端"}
|
| 34 |
D --> E["ZeroGPU 租约"]
|
| 35 |
-
D --> F["普通 GPU /
|
| 36 |
E --> G["Blender 渲染当前批次"]
|
| 37 |
F --> G
|
| 38 |
G --> H["原子保存完成帧"]
|
|
@@ -92,3 +92,7 @@ GPU 配额由 HF 账号、排队和平台规则决定;应用不绕过配额,
|
|
| 92 |
自动化可检查 JSON/ZIP 拒绝路径、任务状态、断点、API 参数、CPU 小场景和 FFmpeg 输出。实际 HF ZeroGPU 的租约、GPU 子进程可见性、驱动/OptiX 兼容性、账号配额、真实场景显存、长时间稳定性必须在目标 Space 验证。自检通过只证明该次小场景成功;不能推出所有工程和无限长度视频都能成功。
|
| 93 |
|
| 94 |
建议部署验收顺序:真实自检 → 低分辨率单帧 → 3 帧连续动画 → 一次中断/恢复 → 下载并核对结果 → 正式任务。只做针对剩余风险的验证,不以重复 CPU 测试代替 GPU 实测。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
# 架构与实施规划
|
| 2 |
|
| 3 |
+
版本:1.1.0。目标是用相同的工程文件与 JSON 参数,把 Blender 渲染任务交给 HF Space,允许人操作界面,也允许 agent 全程使用 API/MCP。交付为可部署代码,不包含已经创建的 Space,也不包含 HF 上真实 ZeroGPU 渲染成功的声明。
|
| 4 |
|
| 5 |
## 可行性结论与技术选择
|
| 6 |
|
| 7 |
+
ZeroGPU 通过 `spaces.GPU` 为函数申请有时间限制的 GPU;官方重点支持 Gradio/PyTorch。Blender Cycles 是外部原生程序,因此平台兼容性是必须实测的前提,而不是装饰器能自动保证的事情。当前采取「租约内启动 Blender 子进程 → 检测 Cycles 设备 → 实际渲染 → 读取报告」路径。平台如果禁止或不支持这条路径,就明确失败并保留日志。普通 NVIDIA GPU Space 作为显式备选,CPU + Eevee 为默认正式工作模式,使用 Mesa 软件 OpenGL,绕过 GPU 租约。[ZeroGPU 文档](https://huggingface.co/docs/hub/en/spaces-zerogpu)
|
| 8 |
|
| 9 |
采用官方 Blender Linux x64 分发包而不是在 Python 中嵌入 `bpy`:保持 Blender 自己的 Python、Cycles 与打包库版本一致,同时便于超时后结束进程树。服务端 Python 3.12、Gradio 6.28.0、spaces 0.51.3;PyTorch 固定为官方支持的 2.8.0,用于 ZeroGPU 平台运行时,真正的图像生成仍由 Blender 完成。[spaces 发布记录](https://pypi.org/project/spaces/0.51.3/)、[Gradio 发布记录](https://pypi.org/project/gradio/6.28.0/)
|
| 10 |
|
|
|
|
| 32 |
B --> C["场景、相机与资源检查"]
|
| 33 |
C --> D{"执行后端"}
|
| 34 |
D --> E["ZeroGPU 租约"]
|
| 35 |
+
D --> F["普通 GPU / 默认 CPU"]
|
| 36 |
E --> G["Blender 渲染当前批次"]
|
| 37 |
F --> G
|
| 38 |
G --> H["原子保存完成帧"]
|
|
|
|
| 92 |
自动化可检查 JSON/ZIP 拒绝路径、任务状态、断点、API 参数、CPU 小场景和 FFmpeg 输出。实际 HF ZeroGPU 的租约、GPU 子进程可见性、驱动/OptiX 兼容性、账号配额、真实场景显存、长时间稳定性必须在目标 Space 验证。自检通过只证明该次小场景成功;不能推出所有工程和无限长度视频都能成功。
|
| 93 |
|
| 94 |
建议部署验收顺序:真实自检 → 低分辨率单帧 → 3 帧连续动画 → 一次中断/恢复 → 下载并核对结果 → 正式任务。只做针对剩余风险的验证,不以重复 CPU 测试代替 GPU 实测。
|
| 95 |
+
|
| 96 |
+
## 1.1.0 更新
|
| 97 |
+
|
| 98 |
+
任务引擎与计算设备独立选择;新配置默认 Eevee / CPU。CPU 路径直接启动 Blender;GPU 路径才进入 spaces.GPU。旧任务缺少 engine 时按 Cycles 续渲,防止混帧。任务文件锁在退出时显式 LOCK_UN,避免 GPU fork 子进程继承文件描述符后继续持锁。
|
docs/DEPLOY.zh-CN.md
CHANGED
|
@@ -31,7 +31,7 @@
|
|
| 31 |
| 超时 | 场景加载与首帧总耗时 | 降低采样/分辨率;单帧不能完成时使用普通 GPU |
|
| 32 |
| 配额用尽或排队拒绝 | HF 调用账号当前配额 | 等待配额恢复;不要无休止自动重试 |
|
| 33 |
|
| 34 |
-
普通 GPU 备选需要你手动选择硬件,并将 `RENDER_BACKEND=gpu`。这可能产生费用;代码不会操作 HF 硬件设置。`
|
| 35 |
|
| 36 |
## 工程准备
|
| 37 |
|
|
@@ -63,7 +63,7 @@ ZIP 可包含如下路径:
|
|
| 63 |
|
| 64 |
| 变量 | 默认值 | 作用 |
|
| 65 |
| --- | --- | --- |
|
| 66 |
-
| `RENDER_BACKEND` | `auto` |
|
| 67 |
| `BLENDER_VERSION` | `5.2.2` | 自动下载的官方 Blender 三段版本号 |
|
| 68 |
| `BLENDER_BIN` | 未设置 | 已安装 Blender 可执行文件绝对路径;优先于自动下载 |
|
| 69 |
| `BLENDER_CACHE` | 用户缓存目录内的 `blender-render-space` | Blender 下载和安装缓存 |
|
|
@@ -115,3 +115,9 @@ python app.py
|
|
| 115 |
若你已配置持久化挂载,可将 `RENDER_DATA_DIR` 指向该挂载的可写目录;本应用不会自动购买或创建持久存储。持久化数据还应自行备份。[HF 存储文档](https://huggingface.co/docs/hub/en/spaces-storage)
|
| 116 |
|
| 117 |
推荐私有 Space、受信任工程、不存放与渲染无关的敏感凭据。随机 job ID 是任务访问凭据,获得 ID 的调用者可以访问任务状态和输出。现有路径控制和禁脚本不是强隔离沙箱,复杂恶意 `.blend` 仍可能触发 Blender 原生漏洞。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
| 超时 | 场景加载与首帧总耗时 | 降低采样/分辨率;单帧不能完成时使用普通 GPU |
|
| 32 |
| 配额用尽或排队拒绝 | HF 调用账号当前配额 | 等待配额恢复;不要无休止自动重试 |
|
| 33 |
|
| 34 |
+
普通 GPU 备选需要你手动选择硬件,并将 `RENDER_BACKEND=gpu`。这可能产生费用;代码不会操作 HF 硬件设置。`cpu_self_test` 测试默认 Eevee CPU;`gpu_self_test` 始终显式测试 Cycles GPU。
|
| 35 |
|
| 36 |
## 工程准备
|
| 37 |
|
|
|
|
| 63 |
|
| 64 |
| 变量 | 默认值 | 作用 |
|
| 65 |
| --- | --- | --- |
|
| 66 |
+
| `RENDER_BACKEND` | `auto` | 仅决定 GPU 任务路由;默认任务 device=CPU 绕过租约。auto/cpu 在显式 GPU 任务中检测 ZeroGPU,否则普通 GPU |
|
| 67 |
| `BLENDER_VERSION` | `5.2.2` | 自动下载的官方 Blender 三段版本号 |
|
| 68 |
| `BLENDER_BIN` | 未设置 | 已安装 Blender 可执行文件绝对路径;优先于自动下载 |
|
| 69 |
| `BLENDER_CACHE` | 用户缓存目录内的 `blender-render-space` | Blender 下载和安装缓存 |
|
|
|
|
| 115 |
若你已配置持久化挂载,可将 `RENDER_DATA_DIR` 指向该挂载的可写目录;本应用不会自动购买或创建持久存储。持久化数据还应自行备份。[HF 存储文档](https://huggingface.co/docs/hub/en/spaces-storage)
|
| 116 |
|
| 117 |
推荐私有 Space、受信任工程、不存放与渲染无关的敏感凭据。随机 job ID 是任务访问凭据,获得 ID 的调用者可以访问任务状态和输出。现有路径控制和禁脚本不是强隔离沙箱,复杂恶意 `.blend` 仍可能触发 Blender 原生漏洞。
|
| 118 |
+
|
| 119 |
+
## 1.1.0 CPU + Eevee
|
| 120 |
+
|
| 121 |
+
默认 engine=EEVEE、device=CPU,CPU 通过 Mesa llvmpipe 软件 OpenGL 运行 Eevee。packages.txt 安装 libegl-mesa0、libgl1-mesa-dri、libglx-mesa0;只在 CPU Eevee 子进程设置 LIBGL_ALWAYS_SOFTWARE、GALLIUM_DRIVER 与 Mesa EGL,不污染 GPU 子进程。保留 ZeroGPU 硬件可让用户按任务选择 GPU,默认 CPU 不申请 GPU 租约。
|
| 122 |
+
|
| 123 |
+
Eevee 在 CPU 上的软件绘制速度取决于工程,不保证快于 Cycles。GPU Eevee 需要硬件图形驱动;ZeroGPU 的 CUDA 可用不等于 EGL/OpenGL 可用。
|
docs/TEST_REPORT-1.1.0.zh-CN.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 1.1.0 测试记录
|
| 2 |
+
|
| 3 |
+
- 单元及回归测试:105 项通过。
|
| 4 |
+
- 验证 CPU 任务在 ZeroGPU 配置下绕过 GPU 租约。
|
| 5 |
+
- 验证 GPU 显式选择、CUDA / OptiX 仅供 Cycles、旧任务保持 Cycles。
|
| 6 |
+
- 验证 fork 子进程仍持有文件描述符时,父进程正确释放任务锁。
|
| 7 |
+
- Blender 5.2.2 LTS 实际 CPU Eevee 渲染成功:Mesa llvmpipe (LLVM 20.1.2, 256 bits)。
|
| 8 |
+
- 本机 Gradio API:上传工程 ZIP 与 JSON、640×360 PNG 渲染、结果 ZIP 下载及完整性检查通过。
|
| 9 |
+
- 线上验证将在部署后追加。Eevee CPU 使用软件 OpenGL,不保证所有场景都快于 Cycles。
|
examples/cycles-gpu.json
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"engine": "CYCLES",
|
| 3 |
+
"device": "AUTO",
|
| 4 |
+
"mode": "image",
|
| 5 |
+
"width": 640,
|
| 6 |
+
"height": 360,
|
| 7 |
+
"samples": 32,
|
| 8 |
+
"gpu_seconds": 120
|
| 9 |
+
}
|
examples/frames-exr.json
CHANGED
|
@@ -12,10 +12,11 @@
|
|
| 12 |
"format": "OPEN_EXR",
|
| 13 |
"color_depth": 16,
|
| 14 |
"transparent": true,
|
| 15 |
-
"device": "
|
| 16 |
"strict_assets": true,
|
| 17 |
-
"batch_size":
|
| 18 |
"gpu_seconds": 120,
|
| 19 |
"cpu_timeout": 600,
|
| 20 |
-
"seed": 0
|
|
|
|
| 21 |
}
|
|
|
|
| 12 |
"format": "OPEN_EXR",
|
| 13 |
"color_depth": 16,
|
| 14 |
"transparent": true,
|
| 15 |
+
"device": "CPU",
|
| 16 |
"strict_assets": true,
|
| 17 |
+
"batch_size": 16,
|
| 18 |
"gpu_seconds": 120,
|
| 19 |
"cpu_timeout": 600,
|
| 20 |
+
"seed": 0,
|
| 21 |
+
"engine": "EEVEE"
|
| 22 |
}
|
examples/image.json
CHANGED
|
@@ -11,10 +11,11 @@
|
|
| 11 |
"format": "PNG",
|
| 12 |
"color_depth": 8,
|
| 13 |
"transparent": false,
|
| 14 |
-
"device": "
|
| 15 |
"strict_assets": true,
|
| 16 |
"batch_size": 1,
|
| 17 |
"gpu_seconds": 120,
|
| 18 |
"cpu_timeout": 600,
|
| 19 |
-
"seed": 0
|
|
|
|
| 20 |
}
|
|
|
|
| 11 |
"format": "PNG",
|
| 12 |
"color_depth": 8,
|
| 13 |
"transparent": false,
|
| 14 |
+
"device": "CPU",
|
| 15 |
"strict_assets": true,
|
| 16 |
"batch_size": 1,
|
| 17 |
"gpu_seconds": 120,
|
| 18 |
"cpu_timeout": 600,
|
| 19 |
+
"seed": 0,
|
| 20 |
+
"engine": "EEVEE"
|
| 21 |
}
|
examples/video.json
CHANGED
|
@@ -15,12 +15,13 @@
|
|
| 15 |
"format": "PNG",
|
| 16 |
"color_depth": 8,
|
| 17 |
"transparent": false,
|
| 18 |
-
"device": "
|
| 19 |
"strict_assets": true,
|
| 20 |
-
"batch_size":
|
| 21 |
"gpu_seconds": 120,
|
| 22 |
"cpu_timeout": 600,
|
| 23 |
"video_crf": 18,
|
| 24 |
"video_preset": "medium",
|
| 25 |
-
"seed": 0
|
|
|
|
| 26 |
}
|
|
|
|
| 15 |
"format": "PNG",
|
| 16 |
"color_depth": 8,
|
| 17 |
"transparent": false,
|
| 18 |
+
"device": "CPU",
|
| 19 |
"strict_assets": true,
|
| 20 |
+
"batch_size": 16,
|
| 21 |
"gpu_seconds": 120,
|
| 22 |
"cpu_timeout": 600,
|
| 23 |
"video_crf": 18,
|
| 24 |
"video_preset": "medium",
|
| 25 |
+
"seed": 0,
|
| 26 |
+
"engine": "EEVEE"
|
| 27 |
}
|
packages.txt
CHANGED
|
@@ -13,3 +13,6 @@ libxxf86vm1
|
|
| 13 |
libxrandr2
|
| 14 |
libxcursor1
|
| 15 |
libwayland-client0
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
libxrandr2
|
| 14 |
libxcursor1
|
| 15 |
libwayland-client0
|
| 16 |
+
libegl-mesa0
|
| 17 |
+
libgl1-mesa-dri
|
| 18 |
+
libglx-mesa0
|
render.schema.json
CHANGED
|
@@ -165,8 +165,17 @@
|
|
| 165 |
"title": "Color Depth",
|
| 166 |
"type": "integer"
|
| 167 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
"device": {
|
| 169 |
-
"default": "
|
| 170 |
"enum": [
|
| 171 |
"AUTO",
|
| 172 |
"CUDA",
|
|
|
|
| 165 |
"title": "Color Depth",
|
| 166 |
"type": "integer"
|
| 167 |
},
|
| 168 |
+
"engine": {
|
| 169 |
+
"default": "EEVEE",
|
| 170 |
+
"enum": [
|
| 171 |
+
"EEVEE",
|
| 172 |
+
"CYCLES"
|
| 173 |
+
],
|
| 174 |
+
"title": "Engine",
|
| 175 |
+
"type": "string"
|
| 176 |
+
},
|
| 177 |
"device": {
|
| 178 |
+
"default": "CPU",
|
| 179 |
"enum": [
|
| 180 |
"AUTO",
|
| 181 |
"CUDA",
|
render_config.py
CHANGED
|
@@ -67,7 +67,8 @@ class RenderConfig(BaseModel):
|
|
| 67 |
fps: Annotated[int, Field(ge=1, le=120)] = 24
|
| 68 |
format: Literal["PNG", "OPEN_EXR"] = "PNG"
|
| 69 |
color_depth: Literal[8, 16, 32] = 8
|
| 70 |
-
|
|
|
|
| 71 |
seed: Annotated[int, Field(ge=0, le=2_147_483_647)] = 0
|
| 72 |
motion_blur: bool = False
|
| 73 |
strict_assets: bool = True
|
|
@@ -105,6 +106,8 @@ class RenderConfig(BaseModel):
|
|
| 105 |
|
| 106 |
@model_validator(mode="after")
|
| 107 |
def validate_render_constraints(self) -> "RenderConfig":
|
|
|
|
|
|
|
| 108 |
if self.frame_end < self.frame_start:
|
| 109 |
raise ValueError("frame_end 不能小于 frame_start。")
|
| 110 |
if self.format == "PNG" and self.color_depth not in (8, 16):
|
|
|
|
| 67 |
fps: Annotated[int, Field(ge=1, le=120)] = 24
|
| 68 |
format: Literal["PNG", "OPEN_EXR"] = "PNG"
|
| 69 |
color_depth: Literal[8, 16, 32] = 8
|
| 70 |
+
engine: Literal["EEVEE", "CYCLES"] = "EEVEE"
|
| 71 |
+
device: Literal["AUTO", "CUDA", "OPTIX", "CPU"] = "CPU"
|
| 72 |
seed: Annotated[int, Field(ge=0, le=2_147_483_647)] = 0
|
| 73 |
motion_blur: bool = False
|
| 74 |
strict_assets: bool = True
|
|
|
|
| 106 |
|
| 107 |
@model_validator(mode="after")
|
| 108 |
def validate_render_constraints(self) -> "RenderConfig":
|
| 109 |
+
if self.engine == "EEVEE" and self.device in {"CUDA", "OPTIX"}:
|
| 110 |
+
raise ValueError("CUDA / OPTIX 仅用于 Cycles;Eevee 请使用 CPU 或 AUTO。")
|
| 111 |
if self.frame_end < self.frame_start:
|
| 112 |
raise ValueError("frame_end 不能小于 frame_start。")
|
| 113 |
if self.format == "PNG" and self.color_depth not in (8, 16):
|
render_worker.py
CHANGED
|
@@ -315,8 +315,13 @@ def inspect_scene(assets):
|
|
| 315 |
"missing_assets": [a for a in assets if a["status"] in {"missing", "blocked"}]}
|
| 316 |
|
| 317 |
|
| 318 |
-
def
|
| 319 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 320 |
for key, default in (("resolution_x", 960), ("resolution_y", 540)):
|
| 321 |
source = "width" if key == "resolution_x" else "height"
|
| 322 |
setattr(scene.render, key, int(config.get(source, default)))
|
|
@@ -330,12 +335,15 @@ def configure_cycles(scene, config):
|
|
| 330 |
scene.render.image_settings.file_format = config.get("format", "PNG")
|
| 331 |
scene.render.image_settings.color_mode = "RGBA"
|
| 332 |
scene.render.image_settings.color_depth = str(config.get("color_depth", 8))
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
|
|
|
|
|
|
|
|
|
| 339 |
if hasattr(scene.render, "use_motion_blur"):
|
| 340 |
scene.render.use_motion_blur = bool(config.get("motion_blur", False))
|
| 341 |
elif hasattr(scene.cycles, "use_motion_blur"):
|
|
@@ -350,7 +358,9 @@ def configure_cycles(scene, config):
|
|
| 350 |
raise ValueError("场景没有活动相机,请在 JSON 中指定 camera 或先在 Blender 中添加相机。")
|
| 351 |
|
| 352 |
|
| 353 |
-
def device_candidates(requested):
|
|
|
|
|
|
|
| 354 |
if requested == "AUTO":
|
| 355 |
return ("OPTIX", "CUDA")
|
| 356 |
if requested not in {"OPTIX", "CUDA", "CPU"}:
|
|
@@ -359,6 +369,18 @@ def device_candidates(requested):
|
|
| 359 |
|
| 360 |
|
| 361 |
def use_device(scene, backend):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 362 |
addon = bpy.context.preferences.addons.get("cycles")
|
| 363 |
if addon is None:
|
| 364 |
raise RuntimeError("此 Blender 安装不包含 Cycles。")
|
|
@@ -441,7 +463,7 @@ def main():
|
|
| 441 |
output_dir.mkdir(parents=True, exist_ok=True)
|
| 442 |
config = task.get("config", {})
|
| 443 |
result = {"ok": False, "action": task["action"], "blender_version": bpy.app.version_string,
|
| 444 |
-
"warnings": [], "devices": [], "backend": None, "frames": [], "timings": {}}
|
| 445 |
start = time.monotonic()
|
| 446 |
try:
|
| 447 |
action = task["action"]
|
|
@@ -450,9 +472,9 @@ def main():
|
|
| 450 |
probe_config = {**config, "width": 32, "height": 32, "resolution_percentage": 100,
|
| 451 |
"samples": 1, "denoise": False, "format": "PNG", "color_depth": "8"}
|
| 452 |
harden_scenes(result["warnings"])
|
| 453 |
-
|
| 454 |
failures = []
|
| 455 |
-
for backend in device_candidates(config.get("device", "
|
| 456 |
try:
|
| 457 |
result["devices"] = use_device(scene, backend)
|
| 458 |
result["backend"] = backend
|
|
@@ -462,7 +484,7 @@ def main():
|
|
| 462 |
except Exception as exc:
|
| 463 |
failures.append(f"{backend}: {exc}")
|
| 464 |
else:
|
| 465 |
-
raise RuntimeError("实际
|
| 466 |
result["warnings"].extend(failures)
|
| 467 |
elif action in {"inspect", "render"}:
|
| 468 |
root = Path(task["project_root"]).resolve(strict=True)
|
|
@@ -480,14 +502,14 @@ def main():
|
|
| 480 |
missing = ", ".join(a["name"] for a in result["missing_assets"][:8])
|
| 481 |
raise ValueError(f"存在缺失或越界资源:{missing}。请上传完整资源,或将 strict_assets 设为 false 后接受缺失效果。")
|
| 482 |
scene = select_scene(config)
|
| 483 |
-
|
| 484 |
frames = task.get("frames") or [int(config.get("frame", config.get("frame_start", scene.frame_current)))]
|
| 485 |
for frame in frames:
|
| 486 |
if not isinstance(frame, int) or isinstance(frame, bool) or abs(frame) > 1_048_574:
|
| 487 |
raise ValueError("帧编号必须是 Blender 支持范围内的整数。")
|
| 488 |
# AUTO 必须通过实际首帧验证;OPTIX 初始化成功也可能在加载内核时失败。
|
| 489 |
failures = []
|
| 490 |
-
for backend in device_candidates(config.get("device", "
|
| 491 |
try:
|
| 492 |
result["devices"] = use_device(scene, backend)
|
| 493 |
result["backend"] = backend
|
|
|
|
| 315 |
"missing_assets": [a for a in assets if a["status"] in {"missing", "blocked"}]}
|
| 316 |
|
| 317 |
|
| 318 |
+
def configure_render(scene, config):
|
| 319 |
+
engine = config.get("engine", "EEVEE")
|
| 320 |
+
if engine == "CYCLES":
|
| 321 |
+
scene.render.engine = "CYCLES"
|
| 322 |
+
else:
|
| 323 |
+
engines = scene.render.bl_rna.properties["engine"].enum_items.keys()
|
| 324 |
+
scene.render.engine = "BLENDER_EEVEE_NEXT" if "BLENDER_EEVEE_NEXT" in engines else "BLENDER_EEVEE"
|
| 325 |
for key, default in (("resolution_x", 960), ("resolution_y", 540)):
|
| 326 |
source = "width" if key == "resolution_x" else "height"
|
| 327 |
setattr(scene.render, key, int(config.get(source, default)))
|
|
|
|
| 335 |
scene.render.image_settings.file_format = config.get("format", "PNG")
|
| 336 |
scene.render.image_settings.color_mode = "RGBA"
|
| 337 |
scene.render.image_settings.color_depth = str(config.get("color_depth", 8))
|
| 338 |
+
if engine == "CYCLES":
|
| 339 |
+
scene.cycles.samples = int(config.get("samples", 64))
|
| 340 |
+
scene.cycles.use_denoising = bool(config.get("denoise", True))
|
| 341 |
+
scene.cycles.use_adaptive_sampling = True
|
| 342 |
+
scene.cycles.adaptive_threshold = float(config.get("adaptive_threshold", 0.01))
|
| 343 |
+
scene.cycles.seed = int(config.get("seed", 0))
|
| 344 |
+
scene.cycles.shading_system = False
|
| 345 |
+
else:
|
| 346 |
+
scene.eevee.taa_render_samples = int(config.get("samples", 64))
|
| 347 |
if hasattr(scene.render, "use_motion_blur"):
|
| 348 |
scene.render.use_motion_blur = bool(config.get("motion_blur", False))
|
| 349 |
elif hasattr(scene.cycles, "use_motion_blur"):
|
|
|
|
| 358 |
raise ValueError("场景没有活动相机,请在 JSON 中指定 camera 或先在 Blender 中添加相机。")
|
| 359 |
|
| 360 |
|
| 361 |
+
def device_candidates(requested, engine="CYCLES"):
|
| 362 |
+
if engine == "EEVEE":
|
| 363 |
+
return ("CPU" if requested == "CPU" else "EEVEE_GPU",)
|
| 364 |
if requested == "AUTO":
|
| 365 |
return ("OPTIX", "CUDA")
|
| 366 |
if requested not in {"OPTIX", "CUDA", "CPU"}:
|
|
|
|
| 369 |
|
| 370 |
|
| 371 |
def use_device(scene, backend):
|
| 372 |
+
if scene.render.engine != "CYCLES":
|
| 373 |
+
import gpu
|
| 374 |
+
if hasattr(gpu, "init"):
|
| 375 |
+
gpu.init()
|
| 376 |
+
renderer = gpu.platform.renderer_get()
|
| 377 |
+
vendor = gpu.platform.vendor_get()
|
| 378 |
+
software = any(tag in renderer.lower() for tag in ("llvmpipe", "softpipe", "swrast", "software", "lavapipe"))
|
| 379 |
+
if backend != "CPU" and software:
|
| 380 |
+
raise RuntimeError("Eevee 未找到硬件图形驱动;当前是软件 OpenGL。请使用 CPU + Eevee,或 GPU + Cycles。")
|
| 381 |
+
if backend == "CPU" and not software:
|
| 382 |
+
raise RuntimeError("CPU Eevee 未确认 Mesa 软件渲染器;拒绝误用硬件 GPU。")
|
| 383 |
+
return [{"name": renderer, "vendor": vendor, "type": "CPU" if software else "GPU", "use": True}]
|
| 384 |
addon = bpy.context.preferences.addons.get("cycles")
|
| 385 |
if addon is None:
|
| 386 |
raise RuntimeError("此 Blender 安装不包含 Cycles。")
|
|
|
|
| 463 |
output_dir.mkdir(parents=True, exist_ok=True)
|
| 464 |
config = task.get("config", {})
|
| 465 |
result = {"ok": False, "action": task["action"], "blender_version": bpy.app.version_string,
|
| 466 |
+
"engine": config.get("engine", "EEVEE"), "warnings": [], "devices": [], "backend": None, "frames": [], "timings": {}}
|
| 467 |
start = time.monotonic()
|
| 468 |
try:
|
| 469 |
action = task["action"]
|
|
|
|
| 472 |
probe_config = {**config, "width": 32, "height": 32, "resolution_percentage": 100,
|
| 473 |
"samples": 1, "denoise": False, "format": "PNG", "color_depth": "8"}
|
| 474 |
harden_scenes(result["warnings"])
|
| 475 |
+
configure_render(scene, probe_config)
|
| 476 |
failures = []
|
| 477 |
+
for backend in device_candidates(config.get("device", "CPU"), config.get("engine", "EEVEE")):
|
| 478 |
try:
|
| 479 |
result["devices"] = use_device(scene, backend)
|
| 480 |
result["backend"] = backend
|
|
|
|
| 484 |
except Exception as exc:
|
| 485 |
failures.append(f"{backend}: {exc}")
|
| 486 |
else:
|
| 487 |
+
raise RuntimeError("实际渲染探针失败。" + " | ".join(failures))
|
| 488 |
result["warnings"].extend(failures)
|
| 489 |
elif action in {"inspect", "render"}:
|
| 490 |
root = Path(task["project_root"]).resolve(strict=True)
|
|
|
|
| 502 |
missing = ", ".join(a["name"] for a in result["missing_assets"][:8])
|
| 503 |
raise ValueError(f"存在缺失或越界资源:{missing}。请上传完整资源,或将 strict_assets 设为 false 后接受缺失效果。")
|
| 504 |
scene = select_scene(config)
|
| 505 |
+
configure_render(scene, config)
|
| 506 |
frames = task.get("frames") or [int(config.get("frame", config.get("frame_start", scene.frame_current)))]
|
| 507 |
for frame in frames:
|
| 508 |
if not isinstance(frame, int) or isinstance(frame, bool) or abs(frame) > 1_048_574:
|
| 509 |
raise ValueError("帧编号必须是 Blender 支持范围内的整数。")
|
| 510 |
# AUTO 必须通过实际首帧验证;OPTIX 初始化成功也可能在加载内核时失败。
|
| 511 |
failures = []
|
| 512 |
+
for backend in device_candidates(config.get("device", "CPU"), config.get("engine", "EEVEE")):
|
| 513 |
try:
|
| 514 |
result["devices"] = use_device(scene, backend)
|
| 515 |
result["backend"] = backend
|
scripts/smoke_test.py
CHANGED
|
@@ -47,7 +47,7 @@ def main():
|
|
| 47 |
client = Client(url, verbose=False, download_files=str(temp / 'downloads'),
|
| 48 |
httpx_kwargs={'trust_env':False})
|
| 49 |
environment = client.predict(api_name='/environment_info')
|
| 50 |
-
probe, _, _ = client.predict(api_name='/
|
| 51 |
assert probe['ok'] and probe['backend'] == 'CPU' and not probe['gpu_verified']
|
| 52 |
job_id, prepared = client.predict([handle_file(str(project))], '{}', handle_file(str(config)),
|
| 53 |
api_name='/prepare_job')
|
|
|
|
| 47 |
client = Client(url, verbose=False, download_files=str(temp / 'downloads'),
|
| 48 |
httpx_kwargs={'trust_env':False})
|
| 49 |
environment = client.predict(api_name='/environment_info')
|
| 50 |
+
probe, _, _ = client.predict(api_name='/cpu_self_test')
|
| 51 |
assert probe['ok'] and probe['backend'] == 'CPU' and not probe['gpu_verified']
|
| 52 |
job_id, prepared = client.predict([handle_file(str(project))], '{}', handle_file(str(config)),
|
| 53 |
api_name='/prepare_job')
|
service.py
CHANGED
|
@@ -61,36 +61,50 @@ def _lock(path: Path):
|
|
| 61 |
if not lock.acquire(blocking=False):
|
| 62 |
raise ValueError('该任务正在执行,请等待或取消当前批次。')
|
| 63 |
handle = None
|
|
|
|
| 64 |
try:
|
| 65 |
handle = (path / '.lock').open('a+')
|
| 66 |
# Linux 文件锁使重入和多进程请求也不能同时更改同一任务。
|
| 67 |
import fcntl
|
| 68 |
try:
|
| 69 |
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
|
|
| 70 |
except BlockingIOError:
|
| 71 |
raise ValueError('该任务正在另一个进程执行。') from None
|
| 72 |
yield
|
| 73 |
finally:
|
| 74 |
if handle is not None:
|
| 75 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
lock.release()
|
| 77 |
|
| 78 |
|
| 79 |
-
def _backend() -> str:
|
|
|
|
|
|
|
|
|
|
| 80 |
choice = os.environ.get('RENDER_BACKEND', 'auto').lower()
|
| 81 |
-
if choice not in {'auto', 'zerogpu', 'gpu', 'cpu'}:
|
| 82 |
-
raise ValueError('RENDER_BACKEND 必须为 auto / zerogpu / gpu / cpu。')
|
| 83 |
from spaces.config import Config
|
| 84 |
zero = bool(Config.zero_gpu)
|
| 85 |
-
if choice
|
| 86 |
return 'zerogpu' if zero else 'gpu'
|
| 87 |
if choice == 'zerogpu' and not zero:
|
| 88 |
-
raise ValueError('当前不是 ZeroGPU 运行环境
|
|
|
|
|
|
|
| 89 |
return choice
|
| 90 |
|
| 91 |
|
| 92 |
def _config(meta: dict) -> RenderConfig:
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
| 94 |
|
| 95 |
|
| 96 |
def _frame_path(path: Path, frame: int, config: RenderConfig) -> Path:
|
|
@@ -180,12 +194,16 @@ def environment_info() -> dict:
|
|
| 180 |
except importlib.metadata.PackageNotFoundError:
|
| 181 |
versions[name] = None
|
| 182 |
return {
|
| 183 |
-
'app_version': '1.
|
| 184 |
'zerogpu_environment': bool(Config.zero_gpu),
|
| 185 |
-
'configured_backend':
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
'ffmpeg_available': bool(shutil.which('ffmpeg')),
|
| 187 |
'gpu_verified': False,
|
| 188 |
-
'message': '
|
| 189 |
'mcp_path': '/gradio_api/mcp/',
|
| 190 |
'job_ttl_hours': int(os.environ.get('JOB_TTL_HOURS', '24')),
|
| 191 |
}
|
|
@@ -210,11 +228,7 @@ def prepare_job(files: list[str], config_json: str, config_file: str | None = No
|
|
| 210 |
raise ValueError('配置文件不得超过 64 KiB。')
|
| 211 |
config_json = p.read_text(encoding='utf-8-sig')
|
| 212 |
cfg = parse_config(config_json)
|
| 213 |
-
backend = _backend()
|
| 214 |
-
if backend != 'cpu' and cfg.device == 'CPU':
|
| 215 |
-
raise ValueError('CPU 渲染必须由管理员设置 RENDER_BACKEND=cpu,GPU 模式不允许静默回退。')
|
| 216 |
-
if backend == 'cpu':
|
| 217 |
-
cfg = cfg.model_copy(update={'device': 'CPU'})
|
| 218 |
_cleanup()
|
| 219 |
# 预留任务目录与数量检查是一个原子步骤,避免并行 API 越过任务上限。
|
| 220 |
import fcntl
|
|
@@ -456,14 +470,22 @@ def finalize_job(job_id: str) -> tuple[dict, list[str], str | None, str | None]:
|
|
| 456 |
return summary, files, image, video
|
| 457 |
|
| 458 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 459 |
def gpu_self_test() -> tuple[dict, list[str], str | None]:
|
| 460 |
-
"""
|
| 461 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 462 |
ensure_blender()
|
| 463 |
token = secrets.token_hex(24)
|
| 464 |
path = JOB_ROOT / ('probe-' + token)
|
| 465 |
path.mkdir()
|
| 466 |
-
cfg = RenderConfig(device='CPU' if backend == 'cpu' else 'AUTO')
|
| 467 |
task = {'action': 'probe', 'project_root': str(path), 'output_dir': str(path / 'frames'),
|
| 468 |
'result_path': str(path / 'probe-result.json'), 'config': cfg.model_dump(), 'frames': [1]}
|
| 469 |
try:
|
|
@@ -472,7 +494,7 @@ def gpu_self_test() -> tuple[dict, list[str], str | None]:
|
|
| 472 |
result['gpu_verified'] = bool(result.get('ok')) and backend != 'cpu'
|
| 473 |
except Exception as exc:
|
| 474 |
result = {'ok': False, 'gpu_verified': False, 'deployment_backend': backend,
|
| 475 |
-
'error': str(exc), 'message': '
|
| 476 |
result = _public_report(result)
|
| 477 |
_atomic_json(path / 'probe-result.json', result)
|
| 478 |
files = [_export(path, path / 'probe-result.json')]
|
|
|
|
| 61 |
if not lock.acquire(blocking=False):
|
| 62 |
raise ValueError('该任务正在执行,请等待或取消当前批次。')
|
| 63 |
handle = None
|
| 64 |
+
acquired = False
|
| 65 |
try:
|
| 66 |
handle = (path / '.lock').open('a+')
|
| 67 |
# Linux 文件锁使重入和多进程请求也不能同时更改同一任务。
|
| 68 |
import fcntl
|
| 69 |
try:
|
| 70 |
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
| 71 |
+
acquired = True
|
| 72 |
except BlockingIOError:
|
| 73 |
raise ValueError('该任务正在另一个进程执行。') from None
|
| 74 |
yield
|
| 75 |
finally:
|
| 76 |
if handle is not None:
|
| 77 |
+
# ZeroGPU forks can inherit this descriptor. Closing the parent copy
|
| 78 |
+
# alone leaves the lock held until all forked copies disappear.
|
| 79 |
+
try:
|
| 80 |
+
if acquired:
|
| 81 |
+
fcntl.flock(handle, fcntl.LOCK_UN)
|
| 82 |
+
finally:
|
| 83 |
+
handle.close()
|
| 84 |
lock.release()
|
| 85 |
|
| 86 |
|
| 87 |
+
def _backend(cfg: RenderConfig | None = None) -> str:
|
| 88 |
+
# Each job explicitly chooses CPU or GPU. Deployment hardware stays available.
|
| 89 |
+
if cfg is None or cfg.device == 'CPU':
|
| 90 |
+
return 'cpu'
|
| 91 |
choice = os.environ.get('RENDER_BACKEND', 'auto').lower()
|
|
|
|
|
|
|
| 92 |
from spaces.config import Config
|
| 93 |
zero = bool(Config.zero_gpu)
|
| 94 |
+
if choice in {'auto', 'cpu'}:
|
| 95 |
return 'zerogpu' if zero else 'gpu'
|
| 96 |
if choice == 'zerogpu' and not zero:
|
| 97 |
+
raise ValueError('当前不是 ZeroGPU 运行环境;请选择 CPU 或配置可用 GPU。')
|
| 98 |
+
if choice not in {'zerogpu', 'gpu'}:
|
| 99 |
+
raise ValueError('RENDER_BACKEND 必须为 auto、zerogpu、gpu 或 cpu。')
|
| 100 |
return choice
|
| 101 |
|
| 102 |
|
| 103 |
def _config(meta: dict) -> RenderConfig:
|
| 104 |
+
saved = dict(meta['config'])
|
| 105 |
+
# Pre-1.1 jobs were always Cycles. Never mix Eevee into a resumed sequence.
|
| 106 |
+
saved.setdefault('engine', 'CYCLES')
|
| 107 |
+
return parse_config(json.dumps(saved))
|
| 108 |
|
| 109 |
|
| 110 |
def _frame_path(path: Path, frame: int, config: RenderConfig) -> Path:
|
|
|
|
| 194 |
except importlib.metadata.PackageNotFoundError:
|
| 195 |
versions[name] = None
|
| 196 |
return {
|
| 197 |
+
'app_version': '1.1.0', 'versions': versions,
|
| 198 |
'zerogpu_environment': bool(Config.zero_gpu),
|
| 199 |
+
'configured_backend': 'cpu',
|
| 200 |
+
'gpu_backend_preference': os.environ.get('RENDER_BACKEND', 'auto'),
|
| 201 |
+
'default_engine': 'EEVEE', 'default_device': 'CPU',
|
| 202 |
+
'default_allocates_gpu': False,
|
| 203 |
+
'supported_engines': ['EEVEE', 'CYCLES'],
|
| 204 |
'ffmpeg_available': bool(shutil.which('ffmpeg')),
|
| 205 |
'gpu_verified': False,
|
| 206 |
+
'message': '默认 CPU + Eevee(Mesa 软件 OpenGL),不申请 GPU。可显式选择 GPU;ZeroGPU 推荐 Cycles + AUTO。',
|
| 207 |
'mcp_path': '/gradio_api/mcp/',
|
| 208 |
'job_ttl_hours': int(os.environ.get('JOB_TTL_HOURS', '24')),
|
| 209 |
}
|
|
|
|
| 228 |
raise ValueError('配置文件不得超过 64 KiB。')
|
| 229 |
config_json = p.read_text(encoding='utf-8-sig')
|
| 230 |
cfg = parse_config(config_json)
|
| 231 |
+
backend = _backend(cfg)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
_cleanup()
|
| 233 |
# 预留任务目录与数量检查是一个原子步骤,避免并行 API 越过任务上限。
|
| 234 |
import fcntl
|
|
|
|
| 470 |
return summary, files, image, video
|
| 471 |
|
| 472 |
|
| 473 |
+
def cpu_self_test() -> tuple[dict, list[str], str | None]:
|
| 474 |
+
"""Render a real Eevee frame on the CPU without requesting a GPU lease."""
|
| 475 |
+
return _self_test(RenderConfig(engine='EEVEE', device='CPU'))
|
| 476 |
+
|
| 477 |
+
|
| 478 |
def gpu_self_test() -> tuple[dict, list[str], str | None]:
|
| 479 |
+
"""Explicitly test Cycles GPU; never invoked by CPU jobs."""
|
| 480 |
+
return _self_test(RenderConfig(engine='CYCLES', device='AUTO'))
|
| 481 |
+
|
| 482 |
+
|
| 483 |
+
def _self_test(cfg: RenderConfig) -> tuple[dict, list[str], str | None]:
|
| 484 |
+
backend = _backend(cfg)
|
| 485 |
ensure_blender()
|
| 486 |
token = secrets.token_hex(24)
|
| 487 |
path = JOB_ROOT / ('probe-' + token)
|
| 488 |
path.mkdir()
|
|
|
|
| 489 |
task = {'action': 'probe', 'project_root': str(path), 'output_dir': str(path / 'frames'),
|
| 490 |
'result_path': str(path / 'probe-result.json'), 'config': cfg.model_dump(), 'frames': [1]}
|
| 491 |
try:
|
|
|
|
| 494 |
result['gpu_verified'] = bool(result.get('ok')) and backend != 'cpu'
|
| 495 |
except Exception as exc:
|
| 496 |
result = {'ok': False, 'gpu_verified': False, 'deployment_backend': backend,
|
| 497 |
+
'error': str(exc), 'message': '渲染自检失败,请查看日志。'}
|
| 498 |
result = _public_report(result)
|
| 499 |
_atomic_json(path / 'probe-result.json', result)
|
| 500 |
files = [_export(path, path / 'probe-result.json')]
|
test-artifacts/api-smoke-report.json
CHANGED
|
@@ -12,7 +12,7 @@
|
|
| 12 |
"api_download_zip_verified": true,
|
| 13 |
"zero_gpu_tested": false,
|
| 14 |
"environment": {
|
| 15 |
-
"app_version": "1.
|
| 16 |
"versions": {
|
| 17 |
"gradio": "6.28.0",
|
| 18 |
"spaces": "0.51.3",
|
|
@@ -20,9 +20,17 @@
|
|
| 20 |
},
|
| 21 |
"zerogpu_environment": false,
|
| 22 |
"configured_backend": "cpu",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
"ffmpeg_available": true,
|
| 24 |
"gpu_verified": false,
|
| 25 |
-
"message": "
|
| 26 |
"mcp_path": "/gradio_api/mcp/",
|
| 27 |
"job_ttl_hours": 24
|
| 28 |
}
|
|
|
|
| 12 |
"api_download_zip_verified": true,
|
| 13 |
"zero_gpu_tested": false,
|
| 14 |
"environment": {
|
| 15 |
+
"app_version": "1.1.0",
|
| 16 |
"versions": {
|
| 17 |
"gradio": "6.28.0",
|
| 18 |
"spaces": "0.51.3",
|
|
|
|
| 20 |
},
|
| 21 |
"zerogpu_environment": false,
|
| 22 |
"configured_backend": "cpu",
|
| 23 |
+
"gpu_backend_preference": "cpu",
|
| 24 |
+
"default_engine": "EEVEE",
|
| 25 |
+
"default_device": "CPU",
|
| 26 |
+
"default_allocates_gpu": false,
|
| 27 |
+
"supported_engines": [
|
| 28 |
+
"EEVEE",
|
| 29 |
+
"CYCLES"
|
| 30 |
+
],
|
| 31 |
"ffmpeg_available": true,
|
| 32 |
"gpu_verified": false,
|
| 33 |
+
"message": "默认 CPU + Eevee(Mesa 软件 OpenGL),不申请 GPU。可显式选择 GPU;ZeroGPU 推荐 Cycles + AUTO。",
|
| 34 |
"mcp_path": "/gradio_api/mcp/",
|
| 35 |
"job_ttl_hours": 24
|
| 36 |
}
|
test-artifacts/cpu-render-preview.png
CHANGED
|
Git LFS Details
|
|
Git LFS Details
|
tests/test_render_modes.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import importlib
|
| 2 |
+
import os
|
| 3 |
+
import sys
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
|
| 8 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 9 |
+
from render_config import RenderConfig, parse_config
|
| 10 |
+
from blender_runtime import _worker_environment
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def test_default_cpu_bypasses_zerogpu_even_when_deployment_is_zerogpu(monkeypatch):
|
| 14 |
+
import service
|
| 15 |
+
monkeypatch.setenv('RENDER_BACKEND', 'zerogpu')
|
| 16 |
+
cfg = parse_config('{}')
|
| 17 |
+
assert (cfg.engine, cfg.device) == ('EEVEE', 'CPU')
|
| 18 |
+
assert service._backend(cfg) == 'cpu'
|
| 19 |
+
calls = []
|
| 20 |
+
monkeypatch.setattr(service, 'run_task', lambda *args: calls.append('cpu') or {'ok': True})
|
| 21 |
+
monkeypatch.setattr(service, '_zero_render', lambda *args: pytest.fail('CPU must not allocate a GPU'))
|
| 22 |
+
service._execute({}, Path('/tmp'), service._backend(cfg), cfg, Path('/tmp/mode-test.log'))
|
| 23 |
+
assert calls == ['cpu']
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def test_gpu_is_opt_in_and_cycles_only_devices_are_validated(monkeypatch):
|
| 27 |
+
import service
|
| 28 |
+
from spaces.config import Config
|
| 29 |
+
monkeypatch.setenv('RENDER_BACKEND', 'auto')
|
| 30 |
+
monkeypatch.setattr(Config, 'zero_gpu', True)
|
| 31 |
+
assert service._backend(RenderConfig(engine='CYCLES', device='AUTO')) == 'zerogpu'
|
| 32 |
+
assert service._backend(RenderConfig(engine='CYCLES', device='CPU')) == 'cpu'
|
| 33 |
+
with pytest.raises(ValueError, match='CUDA / OPTIX'):
|
| 34 |
+
RenderConfig(engine='EEVEE', device='CUDA')
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def test_software_gl_environment_does_not_leak_into_gpu_jobs(monkeypatch):
|
| 38 |
+
monkeypatch.setenv('LIBGL_ALWAYS_SOFTWARE', 'true')
|
| 39 |
+
monkeypatch.setenv('GALLIUM_DRIVER', 'llvmpipe')
|
| 40 |
+
cpu = _worker_environment({'engine': 'EEVEE', 'device': 'CPU'})
|
| 41 |
+
gpu = _worker_environment({'engine': 'EEVEE', 'device': 'AUTO'})
|
| 42 |
+
assert cpu['LIBGL_ALWAYS_SOFTWARE'] == 'true'
|
| 43 |
+
assert cpu['GALLIUM_DRIVER'] == 'llvmpipe'
|
| 44 |
+
assert 'LIBGL_ALWAYS_SOFTWARE' not in gpu
|
| 45 |
+
assert 'GALLIUM_DRIVER' not in gpu
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@pytest.mark.skipif(not hasattr(os, 'fork'), reason='Linux fork regression')
|
| 49 |
+
def test_lock_released_while_forked_worker_still_holds_descriptor(tmp_path):
|
| 50 |
+
import service
|
| 51 |
+
r, w = os.pipe()
|
| 52 |
+
child = None
|
| 53 |
+
try:
|
| 54 |
+
with service._lock(tmp_path):
|
| 55 |
+
child = os.fork()
|
| 56 |
+
if child == 0:
|
| 57 |
+
os.close(w)
|
| 58 |
+
os.read(r, 1)
|
| 59 |
+
os._exit(0)
|
| 60 |
+
# Child still owns an inherited descriptor, but must not own the lock.
|
| 61 |
+
with service._lock(tmp_path):
|
| 62 |
+
pass
|
| 63 |
+
finally:
|
| 64 |
+
if child:
|
| 65 |
+
os.write(w, b'x')
|
| 66 |
+
os.waitpid(child, 0)
|
| 67 |
+
os.close(r)
|
| 68 |
+
os.close(w)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def test_legacy_jobs_resume_with_cycles():
|
| 72 |
+
import service
|
| 73 |
+
assert service._config({'config': {'device': 'AUTO'}}).engine == 'CYCLES'
|
| 74 |
+
assert service._config({'config': {'engine': 'EEVEE', 'device': 'CPU'}}).engine == 'EEVEE'
|
tests/test_service.py
CHANGED
|
@@ -34,7 +34,7 @@ def service(monkeypatch, tmp_path):
|
|
| 34 |
monkeypatch.setattr(module, attribute, value)
|
| 35 |
monkeypatch.setattr(module, "_LOCKS", {})
|
| 36 |
monkeypatch.setattr(module, "ensure_blender", lambda: Path("/test/fake-blender"))
|
| 37 |
-
monkeypatch.setattr(module, "_backend", lambda: "cpu")
|
| 38 |
return module
|
| 39 |
|
| 40 |
|
|
|
|
| 34 |
monkeypatch.setattr(module, attribute, value)
|
| 35 |
monkeypatch.setattr(module, "_LOCKS", {})
|
| 36 |
monkeypatch.setattr(module, "ensure_blender", lambda: Path("/test/fake-blender"))
|
| 37 |
+
monkeypatch.setattr(module, "_backend", lambda cfg=None: "cpu")
|
| 38 |
return module
|
| 39 |
|
| 40 |
|