cpuai commited on
Commit
5d6dcc3
·
verified ·
1 Parent(s): c9b8b64

Default to CPU Eevee; retain optional GPU/Cycles and fix inherited task locks

Browse files
README.md CHANGED
@@ -13,17 +13,26 @@ license: mit
13
 
14
  # Blender Render Studio
15
 
16
- 上传 `.blend`、纹理/缓存 ZIP 和 JSON 渲染配置,使用 Blender Cycles 生成图片、帧序列或 MP4 视频。提供中文 Gradio 界面、API、MCP,以及可连续提交批次、断点续渲、下载结果的 Python 客户端。
17
 
18
- **已在 Hugging Face `cpuai/blender-render-studio` 完成真实 ZeroGPU 渲染验证(2026-09-22)。** Blender 5.2.2 LTS 通过 CUDA 使用 NVIDIA RTX PRO 6000 Blackwell Server Edition 的 MIG 2g.48gb 分区,自检返回 `ok=true`、`gpu_verified=true`;工程 ZIP/JSON 上传、三帧 GPU 渲染、MP4 合成及结果下载也已通过。当前实测环境未发现 OptiX 设备,`AUTO` 正常选择 CUDA,也可显式设置 `device=CUDA`。其他硬件、版本和复杂工程仍应先运行自检;GPU 失败会报错,不会悄悄转为 CPU。[官方兼容性说明](https://huggingface.co/docs/hub/en/spaces-zerogpu)
 
 
 
 
 
 
 
 
 
19
 
20
  ## 能做什么
21
 
22
  - 上传单个已打包资源的 `.blend`,或包含工程与外部资源的 ZIP。
23
  - JSON 设置场景、相机、视图层、分辨率、采样、降噪、透明背景、帧区间和 GPU 时间预算。
24
- - Cycles 的 AUTO / CUDA / OptiX 渲染;管理员可显式选择 CPU 测试模式。
25
  - PNG 8/16 位、OpenEXR 16/32 位帧输出;PNG 序列合成 H.264 MP4。
26
- - 按批申请 GPU,保存完成帧;取消、恢复、导出部分结果与日志。
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** 硬件;是否可选取决于账号与平台当前资格。设置变量 `RENDER_BACKEND=zerogpu`。
36
  4. 等待构建完成。`requirements.txt` 已包含 PyTorch;`packages.txt` 会安装 FFmpeg 与 Blender 的系统依赖。首次启动优先校验并安装 `vendor/` 中的 Blender **5.2.2**;未附带安装包时才从官方下载站下载。完整包已附带安装包,可避免下载站拒绝云平台访问的问题。
37
- 5. 打开「环境自检」页,先查看环境,再运行真实 GPU 渲染自检。只有返回 `ok=true` 且 `gpu_verified=true` 才开始正式 GPU 任务。
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
- "device": "AUTO",
 
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)。实际耗时由场景决定,示例的 GPU 秒数只是请求预算。
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
- 76a3daaeb2dc63ba93f69fc0196255a96c1821c15712303dc72791fdb5dbd9c9 README.md
4
- c9c3c0a625432125e2d67e4ec41cab2da24d4a2a82ea90bb4c2cb4b4f373a1ce app.py
5
- be89bb68c01e6c897ed2c6d2d9adf4b9198069cee154c78d79c2e9595b68551c blender_runtime.py
6
- cfd98db7174045fc2bbd52e3aeab40e73ff055fab14d910f3ae51c20b4100c89 docs/API.zh-CN.md
7
- b0bbcff7f95847cd795389f006a59d57d356ea7f3366bc0ffea5fdf77ad4869e docs/ARCHITECTURE.zh-CN.md
8
- 4416e578fd7f74b38ce5b3bef561bde67d3d9e73c3bb5bb2b7aeb0dd356686d2 docs/DEPLOY.zh-CN.md
 
9
  ca04fb9e7d16e031c7b10ff1dd962839a70118762f086da82722b674bafa8c34 docs/TEST_REPORT.zh-CN.md
10
  15c1033cf2053ed563dda8f8d550bb4c654fa42498c0b18e3f9dd56ea1ed81c8 examples/client_render.py
 
11
  5526526b59e34340d00fd0f50c9eb154b6cbc2a20966e978676016c76246b299 examples/demo.blend
12
- bf20fe326c04e0da1fb73ca274bdeb1aab3f376b6a58436fb61f36b8ef92b4d1 examples/frames-exr.json
13
- adb035c8df07ca5ae548246ae06cf4e4d1d950e5979c654dd2a6d026bebb2bf6 examples/image.json
14
  9c31eed31fe6dbf766c61a8d2a496e10e7217dd6484ea8110664df9876285078 examples/mcp-client.json
15
- 315f3e2a287d0432731ac338226adda7f2a0417f89d16def6f5a19b1814ea851 examples/video.json
16
- e4559cf2075458310e2d45adfa6a194abe3c5f0c190281761d82baa0882a0920 packages.txt
17
- eac6acad35c1807caefed69ff8a24241a5b282aa78d07eff2dc7466ef190aa6b render.schema.json
18
- 9fddaee1f11cdea4e873da26372cf89660d2226b04c5c441c2d8f028872e005d render_config.py
19
- 192dbfb9677d30f47de762c670ccaf4614fc9d3dbe60e5e4710155be79b8bb66 render_worker.py
20
  5e80794bcc6d2549958645e311fe2e150307ca42d1a05b154959c044697327ad requirements-dev.txt
21
  87a9e0631bcf876ec66709de42468e22aad8d55ec7eacfcecc6cb4042b0b9685 requirements.txt
22
  65e361844de06033efda047b4a4092f6d073f03e5bfef61af95cf0af66a1c9a0 scripts/create_demo.py
23
- 0011be2a80cf0b9c33343060c9686673c49dbbb38959eeaedd981de4e1920771 scripts/smoke_test.py
24
- f53672630ccacee5d9f0d6dd255f1e60e5d72b31111500e722b5479faf950703 service.py
25
- 5b460e5eb194c451fe914693222c182a084f4e4d49029248c4f37de7e6ffb442 test-artifacts/api-smoke-report.json
26
  37c0bcbd782040c859387845cb02eefc787628b70da1c591fbcbf3866926d06f test-artifacts/cpu-animation-preview.mp4
27
- 106a45a39d7d4bfc3c42b2366ccff21261afa6b4d62b4757396f6bf7b086dea6 test-artifacts/cpu-render-preview.png
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
- 711e0f562fc6d0bfdc9e4007475f99ceda6a343185d106c016546c5b6dbe3a27 tests/test_service.py
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">Cycles 渲染</span>
 
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
- """界面专用连续模式:每批正常申请 GPU,错误或取消后立即结束。"""
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
- "**ZeroGPU + Blender 为实验性组合。** 首次使用请运行「环境自检」:"
253
- "必须确认 Blender 子进程能在当前 Space 的 GPU 租约内实际渲染。"
254
- "GPU 失败会明确报错;不会静默转为 CPU。"
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
- "不会自动重试。**连续模式可能耗尽 GPU 配额,可随时点击「停止任务」。**"
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 模式用于功能验证;GPU 模式不进行静默 CPU 回退。"
355
  )
356
  with gr.Row():
357
  env_button = gr.Button("读取环境")
358
- probe_button = gr.Button("运行 GPU 自检", variant="primary")
 
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
- | `samples` | 1–4096 | Cycles 最大采样数 |
 
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` | CPU 必须同时由管理员启用 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.0.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 作为显式开发测试模式。[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,7 +32,7 @@ flowchart TD
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,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 硬件设置。`gpu_self_test` 在显式 CPU 模式下会测试 CPU 渲染,但返回 `gpu_verified=false`,不能当作 GPU 成功证据。
35
 
36
  ## 工程准备
37
 
@@ -63,7 +63,7 @@ ZIP 可包含如下路径:
63
 
64
  | 变量 | 默认值 | 作用 |
65
  | --- | --- | --- |
66
- | `RENDER_BACKEND` | `auto` | `auto` 检测 ZeroGPU,否则普通 GPU;可显式 `zerogpu` / `gpu` / `cpu` |
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": "AUTO",
16
  "strict_assets": true,
17
- "batch_size": 1,
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": "AUTO",
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": "AUTO",
19
  "strict_assets": true,
20
- "batch_size": 1,
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": "AUTO",
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
- device: Literal["AUTO", "CUDA", "OPTIX", "CPU"] = "AUTO"
 
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 configure_cycles(scene, config):
319
- scene.render.engine = "CYCLES"
 
 
 
 
 
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
- scene.cycles.samples = int(config.get("samples", 64))
334
- scene.cycles.use_denoising = bool(config.get("denoise", True))
335
- scene.cycles.use_adaptive_sampling = True
336
- scene.cycles.adaptive_threshold = float(config.get("adaptive_threshold", 0.01))
337
- scene.cycles.seed = int(config.get("seed", 0))
338
- scene.cycles.shading_system = False
 
 
 
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
- configure_cycles(scene, probe_config)
454
  failures = []
455
- for backend in device_candidates(config.get("device", "AUTO")):
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("实际 Cycles 探针渲染失败;检测到 CUDA 不代表 ZeroGPU 可以运行 Blender。" + " | ".join(failures))
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
- configure_cycles(scene, config)
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", "AUTO")):
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='/gpu_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')
 
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
- handle.close()
 
 
 
 
 
 
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 == 'auto':
86
  return 'zerogpu' if zero else 'gpu'
87
  if choice == 'zerogpu' and not zero:
88
- raise ValueError('当前不是 ZeroGPU 运行环境。请在 Space 设置选择 ZeroGPU;本地测试请设置 RENDER_BACKEND=cpu 或 gpu。')
 
 
89
  return choice
90
 
91
 
92
  def _config(meta: dict) -> RenderConfig:
93
- return parse_config(json.dumps(meta['config']))
 
 
 
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.0.0', 'versions': versions,
184
  'zerogpu_environment': bool(Config.zero_gpu),
185
- 'configured_backend': os.environ.get('RENDER_BACKEND', 'auto'),
 
 
 
 
186
  'ffmpeg_available': bool(shutil.which('ffmpeg')),
187
  'gpu_verified': False,
188
- 'message': '本接口不分配 GPU。请运行真实渲染自检;ZeroGPU 对 Blender 的兼容性尚需部署验证。',
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
- """Run a real tiny Cycles render on the selected backend and return diagnostics."""
461
- backend = _backend()
 
 
 
 
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': '该环境尚未通过 Blender GPU 渲染验证;请查看日志或改用普通 GPU Space。'}
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.0.0",
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": "本接口不分配 GPU。请运行真实渲染自检;ZeroGPU 对 Blender 的兼容性尚需部署验证。",
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

  • SHA256: 106a45a39d7d4bfc3c42b2366ccff21261afa6b4d62b4757396f6bf7b086dea6
  • Pointer size: 131 Bytes
  • Size of remote file: 311 kB

Git LFS Details

  • SHA256: fde62827730295cf0435bc847b9908787c1df36e78ed85394d78afd3ffc6cc0e
  • Pointer size: 131 Bytes
  • Size of remote file: 322 kB
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