Add unit tests for service functionality and model caching

- Implement tests for ben2 and da3 model caching to ensure models are reused for matching runtime keys.
- Verify service state management, including client registration, job cancellation, and client expiration.
- Test discovery file creation and removal based on service ownership.
- Ensure frame extraction functionality works without models and produces expected results.
This commit is contained in:
2026-08-13 00:35:40 +08:00
parent 7d0983529e
commit 92d2cea1d1
21 changed files with 935 additions and 887 deletions

View File

@@ -17,11 +17,11 @@ SnapForm 是一个 Blender 扩展,可以把一张照片、一段视频或一
3. 点击 `Install from Disk`,选择下载的压缩包
4. 在扩展偏好设置中确认 Storage Root 与 Device(保持默认即可);Device 默认取本机
最快的 CUDA/MPS,否则 CPU。下载超时会自动重试国内镜像,无需手动配置
5. 打开 3D 视图右侧的 `SnapForm` 面板,点击 `Install Backend`
5. 打开 3D 视图右侧的 `SnapForm` 面板,点击 `Install Environment`
DA3、BEN2 源码包和 uv 已内置在扩展里,安装时直接从本地解包,不需要访问 GitHub。首次使用仍会自动
下载运行环境依赖(PyTorch 等),随后自动下载 `DA3 Small` 和 `BEN2 Base`。其他 AI 模型可在
`AI Backend > Models` 中按需下载,请保持网络畅通,首次安装需要一些时间。
`Models` panel 中按需下载,请保持网络畅通,首次安装需要一些时间。
## 快速上手
@@ -55,7 +55,7 @@ confidence 与边拉伸结果会保存为 Surface attributes,再由一个可
以及分割线下方的 `Keep Original`。
界面会显示计算所得的实际 Process Res。剔除和重拓扑参数直接在
Geometry Nodes 修改器中调整;`Unshaded` 可使用与 Depth Plane 相同的 Emission 材质。普通材质的
Bump Scale 会在材质组内乘以 `0.001`,并按透视表面的深度空间缩放。Surface attributes 统一使用 `o_` 前缀;Backend Service 准备过程显示
Bump Scale 会在材质组内乘以 `0.001`,并按透视表面的深度空间缩放。Surface attributes 统一使用 `o_` 前缀;Service 准备过程显示
`Preparing surface data n/6`,可看到当前阶段与总步骤。
### 视频与序列帧
@@ -72,8 +72,8 @@ SnapForm 支持视频(mp4、mkv、mov 等)和序列帧,会逐帧处理并
## 常见问题
- 首次使用需要下载模型,如果下载慢或失败,稍后在 `AI Backend > Models` 里重试即可
- 安装 Backend 时提示下载超时:新版会自动探测并切换到镜像、失败时自动重试一次;如果仍然
- 首次使用需要下载模型,如果下载慢或失败,稍后在 `Models` panel 里重试即可
- 安装 Environment 时提示下载超时:新版会自动探测并切换到镜像、失败时自动重试一次;如果仍然
超时,在扩展偏好设置的 `PyPI Index` 中填入镜像地址(如清华)后重新安装即可
- 找不到右键入口时,请确认选中了 Empty,且显示方式为 Image
- 动画面片不动:检查 Empty 的 `Image Frames` 是否大于 1,并确认播放场景帧

View File

@@ -5,24 +5,24 @@
`SnapForm` 是一套从图片快速构建空间结构的 Blender Extension。目前以
[Depth Anything 3](https://github.com/ByteDance-Seed/Depth-Anything-3)
作为首个 AI 能力,在独立 Backend Environment 中运行 DA3,可将官方导出结果保存在独立 job 目录,
作为首个 AI 能力,在独立 Environment 中运行 DA3,可将官方导出结果保存在独立 job 目录,
也可把 Image Empty 转换成带几何与材质置换的立体面片。Debug 面板还可运行
[BEN2](https://github.com/PramaLLC/BEN2) 背景剔除功能测试。
## 当前功能
- 使用 Blender Extension manifest,可通过 `Install from Disk` 安装
- 在独立 Backend Environment 中通过常驻 Backend Service 运行 Torch、DA3 与 BEN2,避免污染 Blender Python 进程
- Blender 使用内置 `urllib.request` 访问仅监听 `127.0.0.1` 的 Backend Service;请求带随机 Token
- 常驻 Backend Service 复用 Torch、CUDA、BEN2 与最近使用的 DA3 模型,连续执行时不再重复加载
- 多个 Blender 实例自动连接同一个共享 Backend Service;`AI Backend` 面板显示状态、端口、客户端与模型缓存,并提供安装、启动、清理、重启和停止控制
- Backend Environment、模型和任务结果默认保存在 `~/.snapform`,可由多个 Blender 版本共享
- 在独立 Environment 中通过常驻 Service 运行 Torch、DA3 与 BEN2,避免污染 Blender Python 进程
- Blender 使用内置 `urllib.request` 访问仅监听 `127.0.0.1` 的 Service;请求带随机 Token
- 常驻 Service 复用 Torch、CUDA、BEN2 与最近使用的 DA3 模型,连续执行时不再重复加载
- 多个 Blender 实例自动连接同一个共享 Service;`AI Service` 面板显示状态、端口、客户端与模型缓存,并提供安装、启动、清理、重启和停止控制
- Environment、模型和任务结果默认保存在 `~/.snapform`,可由多个 Blender 版本共享
- 支持 DA3 Small、Base、Large 1.1、Giant 1.1 和 Nested Giant Large 1.1
- 支持 BEN2 Base 单图背景剔除测试,输出透明 PNG 与推理耗时
- 支持单张 JPG、JPEG、PNG、WEBP 图片或常见视频文件输入
- 视频按顺序截取前若干帧,可设置参与重建的最大帧数
- `AI Backend > Models` 默认折叠,展开后可逐项查看和下载所有可用模型
- Backend Environment 已就绪时不额外占用面板空间;模型列表每个模型只占一行
- `Models` panel 默认折叠,展开后可逐项查看和下载所有可用模型
- Environment 已安装时不额外占用面板空间;模型列表每个模型只占一行
- `Debug` 默认折叠,内部的 `DA3 Output Test` 与 `BEN2 Removal Test` 也分别默认折叠
- 使用一个输出类型下拉菜单和一个 `Generate Output` 按钮
- 只显示当前格式的专属参数;GLB 与 PLY 设置不会同时出现
@@ -42,7 +42,7 @@
- 转换对话框选择推理模式、处理分辨率与背景移除;细分和置换方式在 Geometry Nodes 修改器中调整
- Debug 的 DA3 输出或 BEN2 背景剔除完成后只打开对应 job 文件夹,不修改当前场景
- 完整保留每次任务生成的所有官方文件
- 安装和模型下载使用后台子进程;推理提交给常驻 Backend Service,状态栏实时显示阶段、百分比进度条与取消按钮
- 安装和模型下载使用后台子进程;推理提交给常驻 Service,状态栏实时显示阶段、百分比进度条与取消按钮
- 后台任务运行时,Install、Download 和 Export 按钮保持原位置并变灰,任务完成后恢复
## 使用
@@ -55,9 +55,9 @@
3. 在 Extension Preferences 中确认 Storage Root 与 Device。Device 是正式功能
(Image Empty 右键菜单)使用的推理设备,默认取本机最快的 CUDA/MPS,否则 CPU,且只列出
本机支持的设备。
4. 在 `AI Backend` 点击 `Install Backend`。首次安装会下载 Python、PyTorch、FastAPI、Uvicorn;
DA3、BEN2 源码包与 uv 已内置在扩展中,安装时从 `~/.snapform/vendor/` 解包使用,
并在 Backend Environment 就绪后自动下载 `DA3 Small` 和 `BEN2 Base` 推荐模型。Backend Environment 安装
4. 在 `AI Service` 点击 `Install Environment`。首次安装会下载 Python、PyTorch、FastAPI、Uvicorn;
DA3、BEN2 源码包与 uv 已内置在扩展中,安装时从 安装任务的临时目录中使用,安装结束后自动清理,
并在 Environment 安装后自动下载 `DA3 Small` 和 `BEN2 Base` 推荐模型。Environment 安装
固定显示 `Environment`、`PyTorch`、`AI components` 三个用户阶段;xFormers(macOS 跳过)、
FastAPI 等内部安装步骤合并在 `AI components` 中。耗时最长的 PyTorch 与 AI 组件占主要进度
区间。安装完成后直接写入 Environment Manifest,不再额外导入全部组件进行验证。推荐模型属于安装
@@ -65,21 +65,21 @@
状态文字使用 `阶段/总数 阶段名 | 当前操作 | 预计剩余时间`,依赖安装会结合预设耗时与实际
进度修正预期;模型下载根据字节数和速度计算剩余时间,例如
`Downloading DA3 Small | 14.2 MB/s | About 2 min left`。重试或切换镜像时进度不会倒退。
Backend Environment 就绪后即使模型下载中断也不影响 Backend Service 使用,可稍后在 `Models` 面板单独补下。
Environment 安装后即使模型下载中断也不影响 Service 使用,可稍后在 `Models` 面板单独补下。
安装不做启动网络预检:pip 安装失败会自动依次重试清华、阿里镜像,模型下载由 worker
自行探测回退到 HF 镜像;需要手动指定镜像时可通过 `PIP_INDEX_URL` 环境变量覆盖。
5. 在 3D View 按 `N`,打开 `SnapForm` 页签。
6. 如需其他模型,展开 `AI Backend > Models`,找到模型并点击该行的 `Download`。
6. 如需其他模型,展开 `Models` panel,找到模型并点击该行的 `Download`。
7. 展开默认收起的 `Debug`,选择 `Output Type` 和本次执行使用的模型。
8. 选择一张图片或一个视频文件。视频可设置从头截取的帧数,然后点击 `Generate Output`。
9. 测试背景剔除时,确认 `AI Backend > Models` 中的 `BEN2 Base` 已就绪,然后在 `Debug` 的
9. 测试背景剔除时,确认 `Models` panel 中的 `BEN2 Base` 已就绪,然后在 `Debug` 的
`BEN2 Removal Test` 区域选择单张图片并点击 `Remove Background`。
完成后会打开 job 文件夹,其中的 `foreground.png` 带透明 Alpha;状态栏显示
BEN2 推理、模型加载与任务总耗时。当前测试固定关闭较慢的 foreground refinement。
## Image Empty 去背景
1. 确认 `AI Backend > Models` 中的 `BEN2 Base` 已就绪;首次安装 Backend 时会默认下载。
1. 确认 `Models` panel 中的 `BEN2 Base` 已就绪;首次安装 Environment 时会默认下载。
2. 在 3D View 选中 Image Empty,右键打开 `SnapForm` 子菜单并选择 `Remove Background`。
3. 完成后以 Fake User 保留原 Image datablock,新建并 Pack 一张透明 PNG 到当前 `.blend`,然后只把
当前 Empty 的 `data` 换成新图;Empty 的 Transform、显示尺寸、Offset 及其他设置保持不变。
@@ -107,16 +107,16 @@ Cyclic 沿用原 Empty 的设置。序列帧无法被 Blender 整体 Pack,因
Resolution Scale,再钳制到 `252–1008`,最后对齐到最接近的 `14` 倍数。Resolution Scale 以百分比
显示,范围为 `1–100%`,默认 `100%`;界面只在标签中实时显示最终 Process Res。
转换导出 `mini_npz`,使用前需要在
`AI Backend > Models` 下载所选模型,并固定使用 `process_res_method="upper_bound_resize"`;设备和参考视图策略
`Models` panel 下载所选模型,并固定使用 `process_res_method="upper_bound_resize"`;设备和参考视图策略
继续使用场景设置。Model 下拉菜单只列出已经下载的 DA3 模型;若没有可用模型,对话框会引导到
`AI Backend > Models` 下载。
`Models` panel 下载。
- `Original`:直接把原图作为深度贴图,不显示估算参数。仅在开启 `Remove Background` 时运行 BEN2
取得前景 Alpha;视频或序列帧按需同时抽取原始帧与前景序列,把原始帧作为 `.depth` 序列。
`Original` 是默认深度来源。选择 `AI Generated` 时,其 Model、Use Ray Pose 与 Resolution Scale
参数会集中显示在独立边框中。`Remove Background` 与 `Unshaded` 直接显示,不再使用 Options 折叠;
`Keep Original` 默认关闭,并在分割线下方保持为最后一项。仅当开启 `Remove Background` 时才需要
`BEN2 Base`,并把 `.foreground` 的 Alpha 作为独立
Mask,不在 Backend Service 中改写深度值。保存 `.foreground` 时保留 BEN2 Alpha,但 RGB 使用未经修改的原始输入;
Mask,不在 Service 中改写深度值。保存 `.foreground` 时保留 BEN2 Alpha,但 RGB 使用未经修改的原始输入;
因此材质直接用 `.foreground` 同时提供颜色和 Mask,不再额外加载原图 Image Texture。
Image Empty 引用视频或序列帧时,AI Generated 会按 Empty 的 Image Frames 数值逐帧估计
深度(未设置时为完整帧数,显式设为 1 时只处理 1 帧),起始帧跟随 Empty 的 Offset,与背景蒙版范围一致;
@@ -167,11 +167,11 @@ Cyclic 沿用原 Empty 的设置。序列帧无法被 Blender 整体 Pack,因
`Convert to Depth Plane`,当前不处理视频或序列帧。
对话框中的 Resolution Scale 与 Depth Plane 使用相同设计:原图长边乘以 `1–100%` 的比例后,
将实际 Process Res 钳制在 `252–1008` 并按 `14` 对齐。
2. Backend Service 请求 DA3 的 `depth`、`conf` 与 `intrinsics`。仅在开启 `Remove Background` 时运行 BEN2 取得
前景 Alpha,并在输出前删除包含背景顶点的三角面;关闭时不加载 BEN2。Depth 不做 smoothing,Backend Service
2. Service 请求 DA3 的 `depth`、`conf` 与 `intrinsics`。仅在开启 `Remove Background` 时运行 BEN2 取得
前景 Alpha,并在输出前删除包含背景顶点的三角面;关闭时不加载 BEN2。Depth 不做 smoothing,Service
反投影深度网格,建立源三角面,计算 confidence percentile 与 camera-space edge stretch,并写入
基于原图像素坐标的源 `UVMap`。这些步骤只准备修改器的输入网格,不执行 Relax、拓扑修复、平滑、
小岛清理或最终 UV 重投影。运行时显示的六个 `Preparing surface data` 步骤均指这段 Backend Service 端源数据准备。
小岛清理或最终 UV 重投影。运行时显示的六个 `Preparing surface data` 步骤均指这段 Service 端源数据准备。
3. Blender 把勾选 `Usage > Modifier` 的 `O DA3 Surface` 节点组直接作为 Geometry Nodes 修改器
添加到 Surface,不另设修改器名称。节点组内没有子节点组,依次完成:
- 修改器顶层依次显示 `Resolution`、`Relax Iterations`、`Smooth`;默认展开的 `Culling` Panel
@@ -192,9 +192,9 @@ Cyclic 沿用原 Empty 的设置。序列帧无法被 Blender 整体 Pack,因
修改器关闭 `show_group_selector`。`Fix Topology` 位于默认折叠的 `Options` Panel。
节点图按 Culling、Remesh、Topology Fix、Island Cleanup 与 UV 分区摆放;各区就近使用独立的
Group Input,并隐藏没有连线的接口,避免跨越整个节点图的输入连线。
5. Backend Service 准备过程固定显示 6 个编号阶段:验证 DA3 数据、反投影、准备 DA3 与可选 BEN2 数据、构建原始
5. Service 准备过程固定显示 6 个编号阶段:验证 DA3 数据、反投影、准备 DA3 与可选 BEN2 数据、构建原始
源三角面、源 UV/attributes、写出网格。状态栏显示 `Preparing surface data n/6: ...`。
6. Backend Service 在 job 根目录写出 `mesh.npz` 与 `texture.png`。NPZ 保存 camera-space `vertices`、
6. Service 在 job 根目录写出 `mesh.npz` 与 `texture.png`。NPZ 保存 camera-space `vertices`、
`faces`、`uv`、`intrinsics`、`image_size` 与有效深度中位数 `reference_depth`。Blender 读取这些
数据后直接创建 Surface 与原图材质,不需要 Open3D 或 OpenCV 进入 Blender Python 环境。
Surface 保留下列结构化 attributes:
@@ -205,7 +205,7 @@ Cyclic 沿用原 Empty 的设置。序列帧无法被 Blender 整体 Pack,因
缩放及父级关系产生的世界变换保持一致。更近的顶点沿 Empty 局部 `+Z` 方向凸出,三角面正面朝向
观察相机,启用 Backface Culling 时可从正面看到。
8. 转换对话框不再提供后处理参数,只保留模型与 Resolution Scale 设置、`Create Matching Camera`、
`Remove Background`、`Unshaded`,以及分割线下方的 `Keep Original`。背景移除默认关闭;开启后 Backend Service
`Remove Background`、`Unshaded`,以及分割线下方的 `Keep Original`。背景移除默认关闭;开启后 Service
使用 BEN2,并在写出 Surface 前直接删除背景三角面,不再由 Geometry Nodes 的 Background 开关处理。
`Unshaded` 使用与 Depth Plane 相同的 Emission/Transparent 材质路径;普通材质的
Bump Scale 按 `abs(object.scale.z) × camera scale_z` 设置,并在材质组内乘以固定基准 `0.001`,
@@ -232,9 +232,9 @@ depth、confidence、intrinsics、extrinsics 与 confidence percentile。当前
所有任务使用统一的状态栏进度显示。变化中的阶段说明显示在右侧的进度条内部,取消按钮
位于进度条右侧。状态字段支持阶段、当前操作与预计剩余秒数,界面统一组合为紧凑文案并保证
进度单调递增。任务进程与 Backend Service 请求在后台启动,按钮点击后立即进入进度状态。Image Depth 的阶段包括 Backend Environment 启动、输入准备、模型加载、深度估算、缓存
进度单调递增。任务进程与 Service 请求在后台启动,按钮点击后立即进入进度状态。Image Depth 的阶段包括 Environment 启动、输入准备、模型加载、深度估算、缓存
写出和面片构建。进度更新会强制刷新状态栏与窗口,无需移动鼠标即可实时显示。安装中断后残留的
半成品 Backend Environment 会在下次安装前自动清理,不会因为中断而无法再次安装。
半成品 Environment 会在下次安装前自动清理,不会因为中断而无法再次安装。
导入过程精确保留 NPZ 内深度值之间的差值。例如深度 `2.0` 和 `5.5` 在
`Displacement Strength = -1` 时,沿局部 Z 轴相差 `3.5 Blender Unit`。它不会把数值压缩到
@@ -267,10 +267,11 @@ Displacement 设置为 `Displacement Only`,AI Generated 与 Original 都使用
通过 image_user 的 Frames / Offset / Start 把第 i 帧映射到场景 `Start+i` 帧,并启用 Auto Refresh。
颜色贴图到 Principled BSDF 之间使用独立的 Shader Node Group。Surface 使用 `O DA3 Surface Layer`;
Depth Plane 使用 `O DA3 Depth Plane Layer` 材质组,并把背景剔除、背景深度与材质置换 Attribute
读取全部收进组内。两者都依次接收 `Color`、`Alpha`、Float 类型的 `Depth`、`Bump Scale`。
`O DA3 Surface Layer` 只输出 `Color`、`Roughness`、`Alpha` 与 `Normal`,不创建 Displacement 节点或输出;
`O DA3 Depth Plane Layer` 另外输出 `Displacement`。Surface 把原图 Color 同时送入 `Color` 与 `Depth`,
保持图像 Bump 效果。外层材质只保留贴图、材质组、Principled BSDF 与 Material Output,方便继续调整。
读取全部收进组内。两者都接收 `Color`、`Alpha` 与 `Bump Scale`,只有 `O DA3 Depth Plane Layer`
额外接收 Float 类型的 `Depth`。
`O DA3 Surface Layer` 只输出 `Color`、`Roughness`、`Alpha` 与 `Normal`,以原图 Color 保留 Bump 效果,
不创建 Displacement 节点或输出;`O DA3 Depth Plane Layer` 另外输出 `Displacement`。外层材质只保留贴图、
材质组、Principled BSDF 与 Material Output,方便继续调整。
Depth Plane 的 Bump Height 直接使用材质组的 `Color` 输入,只受独立的 `Bump Scale` 影响,不使用深度贴图,
也不经过 `Displacement` 模式开关、`Depth Scale` 或 `Background Depth`;材质 Displacement 仍使用这些参数处理后的深度。
无论选择哪种置换方式,原始彩色贴图都会连接
@@ -319,7 +320,7 @@ Blender 为 Packed Image 或序列帧生成的输入副本也归档到对应 job
日志、运行中的状态文件、归档输入与输出产物均直接放在 job 根目录。各类任务名称为:
- `install-runtime`
- `install-environment`
- `download-model`
- `export-reconstruction`
- `generate-depth-plane`
@@ -333,28 +334,28 @@ DA3 官方 exporter 产生的 `gs_ply/`、`depth_vis/`、`exports/mini_npz/` 会
```text
export-reconstruction_20260807-153000-123456/
├── export-reconstruction_20260807-153000-123456.log
├── export-reconstruction_20260807-153000-123456.json
├── job.log
├── status.json
└── scene.glb
export-reconstruction_20260807-153000-123456/
├── export-reconstruction_20260807-153000-123456.log
├── export-reconstruction_20260807-153000-123456.json
├── job.log
├── status.json
└── *.ply
export-reconstruction_20260807-153000-123456/
├── export-reconstruction_20260807-153000-123456.log
├── export-reconstruction_20260807-153000-123456.json
├── job.log
├── status.json
└── *.jpg
export-reconstruction_20260807-153000-123456/
├── export-reconstruction_20260807-153000-123456.log
├── export-reconstruction_20260807-153000-123456.json
├── job.log
├── status.json
└── results.npz
remove-background_20260807-153000-123456/
├── remove-background_20260807-153000-123456.log
├── remove-background_20260807-153000-123456.json
├── job.log
├── status.json
├── foreground.png 单图结果
├── foreground.0001.png… 视频/序列帧结果
├── frame.0001.jpg… 视频/序列帧的源帧副本
@@ -393,15 +394,13 @@ Point Cloud GLB 任务会关闭其默认附带的深度预览,使五种输出
官方格式与限制可参阅
[DA3 Python API 文档](https://github.com/ByteDance-Seed/Depth-Anything-3/blob/main/docs/API.md)。
## AI Backend
## AI Service
AI Backend 是统一上位概念,由两部分组成:Backend Environment 是磁盘上的 Python、依赖和模型执行环境;
Backend Service 是使用该环境启动、接收 Blender 任务的常驻 HTTP 进程。环境的
`environment_manifest.json` 使用 `environment_manifest_revision` 判断安装配方是否匹配;Service 的
`/health` 使用 `job_protocol_version` 判断 Blender 与服务之间的任务协议是否匹配。两者生命周期不同,
因此分别维护版本,而不合并成一个 Schema。
Environment 指磁盘上已安装的 Python、依赖及模型执行组件;Service 是由 Environment 启动、接收 Blender 任务的
常驻 HTTP 进程。`manifest.json` 记录当前 Environment 的安装信息;Service 的 `/health` 返回进程状态和
实例标识。项目发布前不维护安装清单或任务协议的兼容版本号。
Extension 本身不包含 Torch。`Install Backend` 使用 `uv` 创建独立 Python 3.12
Extension 本身不包含 Torch。`Install Environment` 使用 `uv` 创建独立 Python 3.12
环境,并安装。机器上即使没有安装 `uv` 或 Python 也没关系:安装程序由 Blender
自带的 Python 启动,缺 `uv` 时会解包扩展内置的对应平台二进制,再用它下载
Python 3.12 与全部依赖。`uv` 会缓存在扩展存储目录下,后续安装直接复用。
@@ -421,7 +420,7 @@ Windows 上代理若只在系统设置里而未写入环境变量,安装程序
- 固定到已验证 commit 的 Depth Anything 3
- 固定到已验证 commit 的 BEN2
- FastAPI 0.139.2 与 Uvicorn 0.51.0
- DA3 与 BEN2 源码包随扩展打包,安装时复制到 `~/.snapform/vendor/` 后由 uv 本地安装
- DA3 与 BEN2 源码包随扩展打包,安装时复制到临时目录后由 uv 本地安装,完成后立即清理
(无需访问 GitHub);SnapForm 直接调用 DA3
底层 export 接口,`gs_ply` 导出不会触发 `gs_video` 渲染,因此不需要 gsplat
与 C++ 编译工具链
@@ -433,12 +432,12 @@ Windows 上代理若只在系统设置里而未写入环境变量,安装程序
并核对覆盖文件是否仍适用
- `addict` 2.4.0(补充 DA3 上游包元数据暂未声明的运行依赖)
安装 Backend Environment 时会自动选择 PyTorch 后端:检测到 NVIDIA 驱动则安装 CUDA 12.8 版本,否则安装
安装 Environment 时会自动选择 PyTorch 后端:检测到 NVIDIA 驱动则安装 CUDA 12.8 版本,否则安装
CPU 版;macOS 始终安装 CPU 版 PyTorch(MPS 包含其中)。CPU 可以用于验证,但实际推理通常很慢。
Extension Preferences 的 `Device` 决定正式功能的推理设备,默认取本机最快的 CUDA/MPS,否则
CPU;Debug 面板的 `Debug Device` 只影响 Debug 任务。两处设备选项都只列出本机支持的设备:
Windows 不显示 MPS,机器上没有可用 NVIDIA 驱动时不显示 CUDA。Backend Environment 安装完成时会检测真实
可用的设备并写入 `environment_manifest.json`,设备列表以该检测结果为准。
Windows 不显示 MPS,机器上没有可用 NVIDIA 驱动时不显示 CUDA。Environment 安装完成时会检测真实
可用的设备并写入 `manifest.json`,设备列表以该检测结果为准。
CUDA 12.8 的 PyTorch wheel 自带 CUDA 运行库,无需安装 CUDA Toolkit;只需要
NVIDIA 驱动 ≥ 570(数据中心显卡可用更老驱动)。驱动不满足或没有 NVIDIA 显卡时,
设备会自动回退到 CPU(macOS 为 MPS),只有显式选择 CUDA 才会报错。
@@ -448,39 +447,39 @@ macOS(Apple Silicon)会自动从 PyPI 安装 CPU 版 PyTorch,推理设备
Mac(x86_64)轮子,Intel Mac 暂不支持。DA3 射线位姿估计中的 3×3 QR 分解在 MPS
上尚未实现,SnapForm 的覆盖文件会单独把该分解放到 CPU 执行,其余推理仍走 MPS。
Extension 启用后只会查找已有的共享 Backend Service,不会主动启动新进程。第一次执行 AI 操作或在侧边栏
手动点击 `Start` 时才会启动 Backend Service。Backend Service 只绑定回环地址,并让操作系统分配动态端口;第一个实例把
端口、进程和随机 Token 写入 `host.json`,其余 Blender 通过标准库
`urllib.request` 连接同一个 Backend Service。`host.lock` 保证同一 Storage Root 同时只有一个 Backend Service,Token
则阻止同机其他进程直接调用接口。Backend Service 全局同时只执行一个 AI 任务。
Extension 启用后只会查找已有的共享 Service,不会主动启动新进程。第一次执行 AI 操作或在侧边栏
手动点击 `Start` 时才会启动 Service。Service 只绑定回环地址,并让操作系统分配动态端口;第一个实例把
端口、进程和随机 Token 写入 `service.json`,其余 Blender 通过标准库
`urllib.request` 连接同一个 Service。`service.lock` 保证同一 Storage Root 同时只有一个 Service,Token
则阻止同机其他进程直接调用接口。Service 全局同时只执行一个 AI 任务。
每个 Blender 会注册独立客户端并每 5 秒发送心跳。正常关闭 Blender 时先注销自身;如果当前 Backend Service
就是由这个 Blender 启动的,会直接终止它,无需等待。异常退出的客户端会在 10 秒后过期;Backend Service
每个 Blender 会注册独立客户端并每 5 秒发送心跳。正常关闭 Blender 时先注销自身;如果当前 Service
就是由这个 Blender 启动的,会直接终止它,无需等待。异常退出的客户端会在 10 秒后过期;Service
在最后一个客户端离开后仍会保留模型缓存等待 20 秒再退出,作为 Service 由其他进程启动等场景的兜底。
取消任务不会重启 Backend Service,并会保留已经加载的模型;
取消任务不会重启 Service,并会保留已经加载的模型;
如果取消发生在一次不可中断的 Torch 调用中,任务会在该调用返回后的最近检查点结束。
侧边栏 `AI Backend` 面板以 `Running (Clients: n)` 表示空闲服务,以 `Running (Busy)` 表示正在执行任务,
侧边栏 `AI Service` 面板以 `Running (Clients: n)` 表示空闲服务,以 `Running (Busy)` 表示正在执行任务,
并显示缓存模型和活动任务。Blender 完成结果导入时会立即释放当前任务对应的本地 Busy 快照,无需等待
下一次 Backend Service 轮询。空闲且只有当前 Blender 连接时,可以清理模型缓存、重启或停止 Service;
下一次 Service 轮询。空闲且只有当前 Blender 连接时,可以清理模型缓存、重启或停止 Service;
多客户端或任务执行期间不会允许破坏共享服务。
BEN2 在首次使用后保持加载;DA3 保留最近使用的一个模型。切换 DA3 模型时会释放旧模型;加载时如果
显存不足,会先释放另一类缓存模型并重试。
Environment Manifest Revision 变化时,旧 Backend Environment 需要重新执行 `Install Backend`。
Backend Service 的启动和崩溃日志写入 `~/.snapform/ai-host.log`。
Environment Manifest Revision 变化时,旧 Environment 需要重新执行 `Install Environment`。
Service 的启动和崩溃日志写入 `~/.snapform/service.log`。
Backend Environment、模型和任务结果默认保存在用户主目录的 `~/.snapform`:
Environment、模型和任务结果默认保存在用户主目录的 `~/.snapform`:
```text
~/.snapform/
├── runtime/
├── .venv/
├── models/
├── jobs/
├── ai-host.log
├── host.json # Backend Service 运行时存在
└── host.lock # Backend Service 运行时持有
├── service.log
├── service.json # Service 运行时存在
└── service.lock # Service 运行时持有
```
`models/` 下每个模型一个文件夹,命名统一带前缀:DA3 为 `da3_*`(如 `da3_small`、`da3_base`),
@@ -489,7 +488,7 @@ BEN2 为 `ben2_*`(如 `ben2_base`)。旧版无前缀的 DA3 文件夹会在
不同 Blender 版本安装相同 Extension 后会共用这个目录。也可以在 Extension
Preferences 的 `Storage Root` 中修改共享位置。不要把模型写入 Extension 安装目录,
因为该目录可能只读,并且升级时会被替换。不要同时从多个 Blender 实例执行
`Install Backend`;Backend Service、任务队列和模型读取可以共享。
`Install Environment`;Service、任务队列和模型读取可以共享。
## 开发
@@ -511,15 +510,15 @@ uv run python pack.py
├── src/
│ └── snapform/
│ ├── blender_manifest.toml
│ ├── ai_host.py
│ ├── bootstrap_runtime.py
│ ├── service.py
│ ├── install_env.py
│ ├── models.py
│ ├── image_depth.py
│ ├── operators.py
│ ├── panel.py
│ ├── preferences.py
│ ├── properties.py
│ ├── runtime.py
│ ├── client.py
│ └── worker.py
└── tests/
```

View File

@@ -1,6 +1,6 @@
import bpy
from . import runtime
from . import client
from .image_depth import (
ApplyRemovedBackground,
BuildDepthPlane,
@@ -13,7 +13,7 @@ from .image_depth import (
from .image_mesh import BuildImageMesh, ConvertImageToMesh
from .operators import (
CancelJob,
ControlHost,
ControlService,
RunJob,
cancel_active_job,
close_active_job,
@@ -22,7 +22,7 @@ from .panel import (
Ben2RemovalDebugPanel,
Da3OutputDebugPanel,
DebugPanel,
HostPanel,
AiServicePanel,
ModelsPanel,
)
from .preferences import SnapFormPreferences
@@ -35,7 +35,7 @@ CLASSES = (
SnapFormSettings,
RunJob,
CancelJob,
ControlHost,
ControlService,
SnapFormImageMenu,
ApplyRemovedBackground,
RemoveImageBackground,
@@ -44,7 +44,7 @@ CLASSES = (
ConvertImageToDepthPlane,
BuildImageMesh,
ConvertImageToMesh,
HostPanel,
AiServicePanel,
ModelsPanel,
DebugPanel,
Da3OutputDebugPanel,
@@ -61,14 +61,14 @@ def register():
bpy.types.STATUSBAR_HT_header.append(draw_status_bar)
bpy.types.VIEW3D_MT_object_context_menu.append(draw_image_context_menu)
bpy.types.OUTLINER_MT_object.append(draw_image_context_menu)
runtime.schedule_host_start()
client.schedule_service_start()
def unregister():
cancel_active_job()
close_active_job()
runtime.unschedule_host()
runtime.detach_host_client()
client.unschedule_service()
client.detach_service_client()
bpy.types.VIEW3D_MT_object_context_menu.remove(draw_image_context_menu)
bpy.types.OUTLINER_MT_object.remove(draw_image_context_menu)
bpy.types.STATUSBAR_HT_header.remove(draw_status_bar)

View File

@@ -12,8 +12,8 @@ platforms = ["windows-x64", "macos-arm64", "linux-x64"]
license = ["SPDX:GPL-3.0-or-later"]
[permissions]
network = "Access the local Backend Service and download environment dependencies and model weights"
files = "Read source images and store runtime data"
network = "Access the local Service and download environment dependencies and model weights"
files = "Read source images and store environment data"
[build]
paths_exclude_pattern = [

View File

@@ -28,13 +28,11 @@ from .models import (
DA3_COMMIT = "3d835ec1a5802d64a8b8b15f817a1ab54809bfe4"
BEN2_COMMIT = "2c99a5da477b5523585bfa5c893888a6e818a8f6"
ENVIRONMENT_MANIFEST_REVISION = 4
JOB_PROTOCOL_VERSION = 2
ENVIRONMENT_MANIFEST_FILENAME = "environment_manifest.json"
HOST_ADDRESS = "127.0.0.1"
MANIFEST_FILENAME = "manifest.json"
SERVICE_ADDRESS = "127.0.0.1"
DEFAULT_STORAGE_ROOT = Path.home() / ".snapform"
JOB_FOLDER_NAMES = {
"INSTALL_RUNTIME": "install-runtime",
"INSTALL_ENV": "install-environment",
"DOWNLOAD_MODEL": "download-model",
"RECONSTRUCT": "export-reconstruction",
"IMAGE_DEPTH": "generate-depth-plane",
@@ -47,12 +45,12 @@ JOB_FOLDER_NAMES = {
@dataclass(frozen=True)
class HostJobRequest:
class JobRequest:
payload: dict
@dataclass
class HostConnection:
class Connection:
port: int
token: str
pid: int
@@ -60,17 +58,17 @@ class HostConnection:
@dataclass
class LaunchedHost:
class ServiceProcess:
process: subprocess.Popen
log_file: object
_host_connection = None
_launched_host = None
_service_connection = None
_launched_service = None
_registered_instance_id = None
_client_id = uuid.uuid4().hex
_host_auto_start = False
_host_snapshot = {"state": "STOPPED", "message": "Stopped"}
_service_auto_start = False
_service_snapshot = {"state": "STOPPED", "message": "Stopped"}
_local_opener = build_opener(ProxyHandler({}))
_client_label = f"Blender {getattr(bpy.app, 'version_string', '')}".strip()
@@ -92,29 +90,26 @@ def storage_root():
return root
def runtime_directory():
return storage_root() / "runtime"
def venv_directory():
return storage_root() / ".venv"
def runtime_python():
directory = runtime_directory()
def venv_python():
directory = venv_directory()
if os.name == "nt":
return directory / "Scripts" / "python.exe"
return directory / "bin" / "python"
def runtime_ready():
ready_path = runtime_directory() / ENVIRONMENT_MANIFEST_FILENAME
if not runtime_python().is_file() or not ready_path.is_file():
def env_installed():
ready_path = storage_root() / MANIFEST_FILENAME
if not venv_python().is_file() or not ready_path.is_file():
return False
try:
ready = json.loads(ready_path.read_text(encoding="utf-8"))
manifest = json.loads(ready_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return False
return (
ready.get("environment_manifest_revision")
== ENVIRONMENT_MANIFEST_REVISION
)
return isinstance(manifest, dict)
def models_directory():
@@ -143,9 +138,9 @@ def model_ready(model_key):
)
def runtime_devices():
"""Devices detected by the Backend Environment, or None when unknown."""
ready_path = runtime_directory() / ENVIRONMENT_MANIFEST_FILENAME
def env_devices():
"""Devices detected by the installed environment, or None when unknown."""
ready_path = storage_root() / MANIFEST_FILENAME
if not ready_path.is_file():
return None
try:
@@ -159,7 +154,7 @@ def runtime_devices():
def machine_devices():
"""Devices the machine can run before Backend Environment installation."""
"""Devices the machine can run before environment installation."""
devices = ["cpu"]
if shutil.which("nvidia-smi"):
devices.insert(0, "cuda")
@@ -173,7 +168,7 @@ def machine_devices():
def supported_devices():
"""Inference devices available on this machine, fastest first."""
return runtime_devices() or machine_devices()
return env_devices() or machine_devices()
DEVICE_LABELS = {
@@ -249,12 +244,16 @@ def new_job_paths(job_type):
directory = jobs_directory() / stem
directory.mkdir(parents=True, exist_ok=True)
return {
"log": directory / f"{directory.name}.log",
"status": directory / f"{directory.name}.json",
"log": directory / "job.log",
"status": directory / "status.json",
"result": directory,
}
def job_id_from_status(status_path):
return Path(status_path).parent.name
def uv_executable():
"""Return the bundled uv path inside the shared storage."""
return str(uv_cache_path())
@@ -262,25 +261,27 @@ def uv_executable():
def uv_cache_path():
if os.name == "nt":
return storage_root() / "uv" / "uv.exe"
return storage_root() / "uv" / "uv"
return storage_root() / "tools" / "uv.exe"
return storage_root() / "tools" / "uv"
def install_runtime_command(status_path):
def install_env_command(status_path):
executable = uv_executable()
backend = "cu128" if "cuda" in machine_devices() else "cpu"
script = Path(__file__).with_name("bootstrap_runtime.py")
torch_variant = "cu128" if "cuda" in machine_devices() else "cpu"
script = Path(__file__).with_name("install_env.py")
command = [
sys.executable,
str(script),
"--uv",
executable,
"--runtime-dir",
str(runtime_directory()),
"--venv-dir",
str(venv_directory()),
"--manifest",
str(storage_root() / MANIFEST_FILENAME),
"--models-dir",
str(models_directory()),
"--backend",
backend,
"--torch-variant",
torch_variant,
"--da3-commit",
DA3_COMMIT,
"--ben2-commit",
@@ -291,36 +292,36 @@ def install_runtime_command(status_path):
return command
def host_log_path():
return storage_root() / "ai-host.log"
def service_log_path():
return storage_root() / "service.log"
def host_discovery_path():
return storage_root() / "host.json"
def service_discovery_path():
return storage_root() / "service.json"
def host_lock_path():
return storage_root() / "host.lock"
def service_lock_path():
return storage_root() / "service.lock"
def _available_host_port():
def _available_service_port():
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener:
listener.bind((HOST_ADDRESS, 0))
listener.bind((SERVICE_ADDRESS, 0))
return listener.getsockname()[1]
def _host_url(connection, path):
return f"http://{HOST_ADDRESS}:{connection.port}{path}"
def _service_url(connection, path):
return f"http://{SERVICE_ADDRESS}:{connection.port}{path}"
def _host_http_request(connection, method, path, payload=None, timeout=2.0):
def _service_http_request(connection, method, path, payload=None, timeout=2.0):
data = None
headers = {"X-SnapForm-Token": connection.token}
if payload is not None:
data = json.dumps(payload).encode("utf-8")
headers["Content-Type"] = "application/json"
request = Request(
_host_url(connection, path),
_service_url(connection, path),
data=data,
headers=headers,
method=method,
@@ -334,16 +335,16 @@ def _host_http_request(connection, method, path, payload=None, timeout=2.0):
except (json.JSONDecodeError, UnicodeDecodeError):
detail = None
raise RuntimeError(
detail or f"Backend Service returned HTTP {error.code}"
detail or f"Service returned HTTP {error.code}"
)
except (OSError, URLError) as error:
raise RuntimeError(f"Unable to reach Backend Service: {error}")
raise RuntimeError(f"Unable to reach Service: {error}")
def _read_host_discovery():
def _read_service_discovery():
try:
payload = json.loads(host_discovery_path().read_text(encoding="utf-8"))
return HostConnection(
payload = json.loads(service_discovery_path().read_text(encoding="utf-8"))
return Connection(
port=int(payload["port"]),
token=str(payload["token"]),
pid=int(payload["pid"]),
@@ -353,46 +354,44 @@ def _read_host_discovery():
return None
def _check_host(connection, timeout=0.3):
health = _host_http_request(
def _check_service(connection, timeout=0.3):
health = _service_http_request(
connection,
"GET",
"/health",
timeout=timeout,
)
if health.get("job_protocol_version") != JOB_PROTOCOL_VERSION:
raise RuntimeError("Backend Service job protocol is out of date")
if health.get("instance_id") != connection.instance_id:
raise RuntimeError("Backend Service discovery is stale")
raise RuntimeError("Service discovery is stale")
return health
def _discover_live_host(timeout=0.3):
connection = _read_host_discovery()
def _discover_live_service(timeout=0.3):
connection = _read_service_discovery()
if connection is None:
return None, None
try:
return connection, _check_host(connection, timeout=timeout)
return connection, _check_service(connection, timeout=timeout)
except RuntimeError:
return None, None
def start_host():
global _launched_host
def start_service():
global _launched_service
if _launched_host is not None and _launched_host.process.poll() is None:
if _launched_service is not None and _launched_service.process.poll() is None:
return
_close_launched_host()
port = _available_host_port()
_close_launched_service()
port = _available_service_port()
token = secrets.token_urlsafe(32)
instance_id = uuid.uuid4().hex
log_file = host_log_path().open("a", encoding="utf-8")
log_file = service_log_path().open("a", encoding="utf-8")
command = [
str(runtime_python()),
str(venv_python()),
"-u",
str(Path(__file__).with_name("ai_host.py")),
str(Path(__file__).with_name("service.py")),
"--host",
HOST_ADDRESS,
SERVICE_ADDRESS,
"--port",
str(port),
"--token",
@@ -400,9 +399,9 @@ def start_host():
"--instance-id",
instance_id,
"--discovery",
str(host_discovery_path()),
str(service_discovery_path()),
"--lock",
str(host_lock_path()),
str(service_lock_path()),
]
try:
process = subprocess.Popen(
@@ -415,46 +414,46 @@ def start_host():
except OSError:
log_file.close()
raise
_launched_host = LaunchedHost(process, log_file)
_launched_service = ServiceProcess(process, log_file)
def ensure_host(timeout=60.0):
global _host_auto_start, _host_connection, _host_snapshot
def ensure_service(timeout=60.0):
global _service_auto_start, _service_connection, _service_snapshot
_host_auto_start = True
_service_auto_start = True
deadline = time.monotonic() + timeout
last_launch = 0.0
starting = False
while time.monotonic() < deadline:
connection, health = _discover_live_host(timeout=0.5)
connection, health = _discover_live_service(timeout=0.5)
if connection is not None:
_host_connection = connection
_register_host_client(connection)
_host_snapshot = _format_host_snapshot(connection, health)
_redraw_host_ui()
_service_connection = connection
_register_service_client(connection)
_service_snapshot = _format_service_snapshot(connection, health)
_redraw_service_ui()
return connection
if not starting:
_host_snapshot = {"state": "STARTING", "message": "Starting"}
_redraw_host_ui()
_service_snapshot = {"state": "STARTING", "message": "Starting"}
_redraw_service_ui()
starting = True
_close_finished_launched_host()
_close_finished_launched_service()
if (
_launched_host is None
_launched_service is None
and time.monotonic() - last_launch >= 1.0
):
start_host()
start_service()
last_launch = time.monotonic()
time.sleep(0.05)
raise RuntimeError(
f"Backend Service did not become ready; log: {host_log_path()}"
f"Service did not become ready; log: {service_log_path()}"
)
def submit_host_job(request):
connection = _host_connection or ensure_host()
def submit_service_job(request):
connection = _service_connection or ensure_service()
payload = dict(request.payload)
payload["client_id"] = _client_id
_host_http_request(
_service_http_request(
connection,
"POST",
"/jobs",
@@ -464,79 +463,79 @@ def submit_host_job(request):
return connection
def stop_host():
global _host_auto_start, _host_connection, _host_snapshot
def stop_service():
global _service_auto_start, _service_connection, _service_snapshot
global _registered_instance_id
connection = _host_connection
connection = _service_connection
if connection is None:
connection, _health = _discover_live_host(timeout=0.5)
connection, _health = _discover_live_service(timeout=0.5)
if connection is None:
_close_launched_host(terminate=True)
_host_auto_start = False
_host_snapshot = {"state": "STOPPED", "message": "Stopped"}
_close_launched_service(terminate=True)
_service_auto_start = False
_service_snapshot = {"state": "STOPPED", "message": "Stopped"}
return
_register_host_client(connection)
_host_http_request(
_register_service_client(connection)
_service_http_request(
connection,
"POST",
"/shutdown",
payload={"client_id": _client_id},
timeout=2.0,
)
_host_auto_start = False
_service_auto_start = False
deadline = time.monotonic() + 10.0
while time.monotonic() < deadline:
try:
_check_host(connection, timeout=0.2)
_check_service(connection, timeout=0.2)
except RuntimeError:
break
time.sleep(0.05)
else:
raise RuntimeError("Backend Service did not stop")
_host_connection = None
raise RuntimeError("Service did not stop")
_service_connection = None
_registered_instance_id = None
_close_finished_launched_host()
_host_snapshot = {"state": "STOPPED", "message": "Stopped"}
_close_finished_launched_service()
_service_snapshot = {"state": "STOPPED", "message": "Stopped"}
def restart_host():
global _host_auto_start, _host_connection, _registered_instance_id
global _host_snapshot
def restart_service():
global _service_auto_start, _service_connection, _registered_instance_id
global _service_snapshot
connection = ensure_host()
_host_http_request(
connection = ensure_service()
_service_http_request(
connection,
"POST",
"/shutdown",
payload={"client_id": _client_id},
timeout=2.0,
)
_host_auto_start = True
_host_connection = None
_service_auto_start = True
_service_connection = None
_registered_instance_id = None
_host_snapshot = {"state": "STARTING", "message": "Restarting"}
_service_snapshot = {"state": "STARTING", "message": "Restarting"}
def enable_host():
global _host_auto_start, _host_connection, _host_snapshot
def enable_service():
global _service_auto_start, _service_connection, _service_snapshot
_host_auto_start = True
connection, health = _discover_live_host(timeout=0.2)
_service_auto_start = True
connection, health = _discover_live_service(timeout=0.2)
if connection is not None:
_host_connection = connection
_register_host_client(connection)
_host_snapshot = _format_host_snapshot(connection, health)
_redraw_host_ui()
_service_connection = connection
_register_service_client(connection)
_service_snapshot = _format_service_snapshot(connection, health)
_redraw_service_ui()
return
start_host()
_host_snapshot = {"state": "STARTING", "message": "Starting"}
_redraw_host_ui()
start_service()
_service_snapshot = {"state": "STARTING", "message": "Starting"}
_redraw_service_ui()
def clear_host_models():
connection = ensure_host()
return _host_http_request(
def clear_service_models():
connection = ensure_service()
return _service_http_request(
connection,
"POST",
"/models/clear",
@@ -544,9 +543,9 @@ def clear_host_models():
)
def cancel_host_job(job_id):
connection = ensure_host()
return _host_http_request(
def cancel_service_job(job_id):
connection = ensure_service()
return _service_http_request(
connection,
"DELETE",
f"/jobs/{job_id}",
@@ -555,13 +554,13 @@ def cancel_host_job(job_id):
)
def detach_host_client():
global _host_connection, _registered_instance_id
def detach_service_client():
global _service_connection, _registered_instance_id
connection = _host_connection
connection = _service_connection
if connection is not None and _registered_instance_id is not None:
try:
_host_http_request(
_service_http_request(
connection,
"DELETE",
f"/clients/{_client_id}",
@@ -569,48 +568,48 @@ def detach_host_client():
)
except RuntimeError:
pass
_host_connection = None
_service_connection = None
_registered_instance_id = None
# 当前 Blender 启动的 Service 随 Blender 关闭,不等待空闲超时。
_close_launched_host(terminate=True)
_close_launched_service(terminate=True)
def schedule_host_start():
def schedule_service_start():
timers = getattr(bpy.app, "timers", None)
if timers is None:
return
if timers.is_registered(_service_host_timer):
if timers.is_registered(_service_timer):
return
timers.register(_service_host_timer, first_interval=1.0, persistent=True)
timers.register(_service_timer, first_interval=1.0, persistent=True)
def unschedule_host():
def unschedule_service():
timers = getattr(bpy.app, "timers", None)
if timers is None:
return
if timers.is_registered(_service_host_timer):
timers.unregister(_service_host_timer)
if timers.is_registered(_service_timer):
timers.unregister(_service_timer)
def host_status():
return dict(_host_snapshot)
def service_status():
return dict(_service_snapshot)
def host_busy():
return _host_snapshot.get("state") == "BUSY"
def service_busy():
return _service_snapshot.get("state") == "BUSY"
def mark_host_job_complete(job_id):
def mark_service_job_complete(job_id):
"""Release a stale local busy snapshot after its terminal status is observed."""
global _host_snapshot
global _service_snapshot
active = _host_snapshot.get("active_job")
active = _service_snapshot.get("active_job")
if active and active.get("job_id") != job_id:
return
if _host_snapshot.get("state") != "BUSY":
if _service_snapshot.get("state") != "BUSY":
return
_host_snapshot = {
**_host_snapshot,
_service_snapshot = {
**_service_snapshot,
"busy": False,
"active_job": None,
"state": "READY",
@@ -618,38 +617,38 @@ def mark_host_job_complete(job_id):
}
def _service_host_timer():
global _host_connection, _host_snapshot, _registered_instance_id
def _service_timer():
global _service_connection, _service_snapshot, _registered_instance_id
if not runtime_ready():
_host_snapshot = {
if not env_installed():
_service_snapshot = {
"state": "UNAVAILABLE",
"message": "Backend Environment is not installed",
"message": "Environment is not installed",
}
return 2.0
connection, health = _discover_live_host(timeout=0.2)
connection, health = _discover_live_service(timeout=0.2)
if connection is None:
_host_connection = None
_service_connection = None
_registered_instance_id = None
_close_finished_launched_host()
if not _host_auto_start:
if _host_snapshot.get("state") != "STOPPED":
_host_snapshot = {"state": "STOPPED", "message": "Stopped"}
_redraw_host_ui()
_close_finished_launched_service()
if not _service_auto_start:
if _service_snapshot.get("state") != "STOPPED":
_service_snapshot = {"state": "STOPPED", "message": "Stopped"}
_redraw_service_ui()
return 2.0
try:
start_host()
start_service()
except OSError as error:
_host_snapshot = {"state": "ERROR", "message": str(error)}
_service_snapshot = {"state": "ERROR", "message": str(error)}
return 2.0
_host_snapshot = {"state": "STARTING", "message": "Starting"}
_redraw_host_ui()
_service_snapshot = {"state": "STARTING", "message": "Starting"}
_redraw_service_ui()
return 1.0
try:
_host_connection = connection
_service_connection = connection
if _registered_instance_id == connection.instance_id:
try:
_host_http_request(
_service_http_request(
connection,
"POST",
f"/clients/{_client_id}/heartbeat",
@@ -657,23 +656,23 @@ def _service_host_timer():
)
except RuntimeError:
_registered_instance_id = None
_register_host_client(connection)
health = _check_host(connection, timeout=0.5)
_host_snapshot = _format_host_snapshot(connection, health)
_register_service_client(connection)
health = _check_service(connection, timeout=0.5)
_service_snapshot = _format_service_snapshot(connection, health)
except RuntimeError as error:
_host_connection = None
_service_connection = None
_registered_instance_id = None
_host_snapshot = {"state": "ERROR", "message": str(error)}
_redraw_host_ui()
_service_snapshot = {"state": "ERROR", "message": str(error)}
_redraw_service_ui()
return 5.0
def _register_host_client(connection):
def _register_service_client(connection):
global _registered_instance_id
if _registered_instance_id == connection.instance_id:
return
_host_http_request(
_service_http_request(
connection,
"POST",
"/clients",
@@ -683,7 +682,7 @@ def _register_host_client(connection):
_registered_instance_id = connection.instance_id
def _format_host_snapshot(connection, health):
def _format_service_snapshot(connection, health):
state = "BUSY" if health.get("busy") else "READY"
return {
**health,
@@ -693,7 +692,7 @@ def _format_host_snapshot(connection, health):
}
def _redraw_host_ui():
def _redraw_service_ui():
if threading.current_thread() is not threading.main_thread():
return
try:
@@ -704,17 +703,17 @@ def _redraw_host_ui():
pass
def _close_finished_launched_host():
if _launched_host is None or _launched_host.process.poll() is None:
def _close_finished_launched_service():
if _launched_service is None or _launched_service.process.poll() is None:
return
_close_launched_host()
_close_launched_service()
def _close_launched_host(terminate=False):
global _launched_host
def _close_launched_service(terminate=False):
global _launched_service
launched = _launched_host
_launched_host = None
launched = _launched_service
_launched_service = None
if launched is None:
return
if terminate and launched.process.poll() is None:
@@ -730,7 +729,7 @@ def _close_launched_host(terminate=False):
def download_model_command(model_key, status_path):
model = get_downloadable_model(model_key)
command = [
str(runtime_python()),
str(venv_python()),
str(Path(__file__).with_name("worker.py")),
"download",
"--repository",
@@ -757,9 +756,9 @@ def remove_background_request(
start_frame=1,
device=None,
):
return HostJobRequest(
return JobRequest(
{
"job_id": Path(status_path).stem,
"job_id": job_id_from_status(status_path),
"command": "remove-background",
"parameters": {
"input": str(input_path),
@@ -786,9 +785,9 @@ def extract_frames_request(
video_frames=None,
start_frame=1,
):
return HostJobRequest(
return JobRequest(
{
"job_id": Path(status_path).stem,
"job_id": job_id_from_status(status_path),
"command": "extract-frames",
"parameters": {
"input": str(input_path),
@@ -867,9 +866,9 @@ def reconstruct_request(
parameters["remove_background_model_dir"] = str(
model_directory(BEN2_MODEL.key)
)
return HostJobRequest(
return JobRequest(
{
"job_id": Path(status_path).stem,
"job_id": job_id_from_status(status_path),
"command": "reconstruct",
"parameters": parameters,
}

View File

@@ -49,7 +49,7 @@ VIDEO_NAME_SUFFIXES = (
def image_depth_model_items(owner, context):
from .models import model_enum_items
from .runtime import model_ready
from .client import model_ready
return [
item
@@ -223,7 +223,7 @@ def draw_process_resolution(layout, owner, image):
def save_packed_image(image, scene):
from .runtime import temporary_input_path
from .client import temporary_input_path
output_path = temporary_input_path()
image_settings = scene.render.image_settings
@@ -281,7 +281,7 @@ def prepare_image_input(image, scene):
return source_path, False
sequence_frames = collect_sequence_frames(image)
if sequence_frames:
from .runtime import temporary_input_directory
from .client import temporary_input_directory
temporary_directory = Path(temporary_input_directory())
try:
@@ -881,12 +881,14 @@ def material_node_group(depth_plane=False):
return existing
group = bpy.data.node_groups.new(group_name, "ShaderNodeTree")
interface = group.interface
for name, socket_type in (
input_sockets = [
("Color", "NodeSocketColor"),
("Alpha", "NodeSocketFloat"),
("Depth", "NodeSocketFloat"),
("Bump Scale", "NodeSocketFloat"),
):
]
if depth_plane:
input_sockets.insert(2, ("Depth", "NodeSocketFloat"))
for name, socket_type in input_sockets:
socket = interface.new_socket(
name=name,
in_out="INPUT",
@@ -934,7 +936,6 @@ def material_node_group(depth_plane=False):
displacement = None
processed_alpha = group_input.outputs["Alpha"]
processed_depth = group_input.outputs["Depth"]
bump_depth = group_input.outputs["Color"]
if depth_plane:
displacement = nodes.new("ShaderNodeDisplacement")
@@ -1174,8 +1175,6 @@ def create_material(
output.inputs["Displacement"],
)
set_displacement_only(material)
else:
links.new(color_texture.outputs["Color"], material_inputs.inputs["Depth"])
return material
@@ -1700,13 +1699,13 @@ class RemoveImageBackground(bpy.types.Operator):
@classmethod
def poll(cls, context):
from .operators import active_job
from .runtime import host_busy, model_ready
from .client import service_busy, model_ready
from .models import BEN2_MODEL
return (
is_image_empty(context.object)
and active_job() is None
and not host_busy()
and not service_busy()
and model_ready(BEN2_MODEL.key)
)
@@ -1790,13 +1789,13 @@ class ConvertImageToDepthPlane(bpy.types.Operator):
@classmethod
def poll(cls, context):
from .operators import active_job
from .runtime import host_busy, model_ready
from .client import service_busy, model_ready
from .models import MODELS
return (
is_image_empty(context.object)
and active_job() is None
and not host_busy()
and not service_busy()
and any(model_ready(model_key) for model_key in MODELS)
)
@@ -1823,7 +1822,7 @@ class ConvertImageToDepthPlane(bpy.types.Operator):
available_models = image_depth_model_items(self, context)
if not available_models:
estimate_box.label(text="No DA3 models installed", icon="ERROR")
estimate_box.label(text="Download one in AI Backend > Models")
estimate_box.label(text="Download one in Models")
else:
estimate_box.prop(self, "snapform_model")
estimate_box.prop(self, "snapform_use_ray_pose")
@@ -1917,17 +1916,17 @@ class SnapFormImageMenu(bpy.types.Menu):
bl_label = "SnapForm"
def draw(self, _context):
from .runtime import runtime_ready
from .client import env_installed
layout = self.layout
layout.operator_context = "INVOKE_DEFAULT"
if not runtime_ready():
if not env_installed():
operator = layout.operator(
"snapform.run_job",
text="Install Backend",
text="Install Environment",
icon="IMPORT",
)
operator.job_type = "INSTALL_RUNTIME"
operator.job_type = "INSTALL_ENV"
return
layout.operator(
"snapform.convert_image_to_depth_plane",

View File

@@ -517,13 +517,13 @@ class ConvertImageToMesh(bpy.types.Operator):
def poll(cls, context):
from .models import MODELS
from .operators import active_job
from .runtime import host_busy, model_ready
from .client import service_busy, model_ready
return (
is_image_empty(context.object)
and video_total_frames(context.object.data) == 1
and active_job() is None
and not host_busy()
and not service_busy()
and any(model_ready(model_key) for model_key in MODELS)
)

View File

@@ -34,8 +34,6 @@ XFORMERS_VERSION = "0.0.33.post2"
ADDICT_VERSION = "2.4.0"
FASTAPI_VERSION = "0.139.2"
UVICORN_VERSION = "0.51.0"
ENVIRONMENT_MANIFEST_REVISION = 4
ENVIRONMENT_MANIFEST_FILENAME = "environment_manifest.json"
TORCH_INDEXES = {
"cu128": "https://download.pytorch.org/whl/cu128",
"cpu": "https://download.pytorch.org/whl/cpu",
@@ -148,8 +146,8 @@ def estimated_remaining(label, progress, elapsed=0.0):
return max(expected_seconds * remaining_fraction - elapsed, 0.0)
def effective_torch_index(backend):
return os.environ.get("SNAPFORM_TORCH_INDEX") or TORCH_INDEXES.get(backend)
def effective_torch_index(torch_variant):
return os.environ.get("SNAPFORM_TORCH_INDEX") or TORCH_INDEXES.get(torch_variant)
def resolve_indexes(args):
@@ -304,21 +302,23 @@ def run_install(
print(f"Retrying with PyPI mirror: {PYPI_MIRRORS[0]}")
def environment_python(runtime_dir):
def venv_python(venv_dir):
if sys.platform == "win32":
return Path(runtime_dir) / "Scripts" / "python.exe"
return Path(runtime_dir) / "bin" / "python"
return Path(venv_dir) / "Scripts" / "python.exe"
return Path(venv_dir) / "bin" / "python"
def install_stages(args, runtime_dir, python, uv):
def install_stages(args, venv_dir, python, uv, vendor_dir=None):
# macOS 的 PyTorch wheel 只在 PyPI 上,download.pytorch.org 没有 macosx 构建
torch_index = (
None
if sys.platform == "darwin"
else effective_torch_index(args.backend)
else effective_torch_index(args.torch_variant)
)
vendor_dir = Path(vendor_dir or Path(venv_dir).parent / "vendor")
def archive_requirement(project_name, filename):
local = Path(args.runtime_dir).parent / "vendor" / filename
local = Path(vendor_dir) / filename
if not local.is_file():
raise RuntimeError(
"Bundled vendor artifact is missing; reinstall the extension: "
@@ -349,7 +349,7 @@ def install_stages(args, runtime_dir, python, uv):
stages = [
(
"Environment",
[uv, "venv", "--clear", "--python", "3.12", runtime_dir],
[uv, "venv", "--clear", "--python", "3.12", venv_dir],
None,
"plain",
),
@@ -390,7 +390,7 @@ def install_stages(args, runtime_dir, python, uv):
python,
f"xformers=={XFORMERS_VERSION}",
]
if args.backend == "cu128":
if args.torch_variant == "cu128":
xformers_command.extend(["--index-url", torch_index])
else:
xformers_command.append("--no-deps")
@@ -429,18 +429,15 @@ def install_stages(args, runtime_dir, python, uv):
return stages
def runtime_ready(runtime_dir):
ready_path = runtime_dir / ENVIRONMENT_MANIFEST_FILENAME
if not environment_python(runtime_dir).is_file() or not ready_path.is_file():
def env_installed(venv_dir, manifest_path=None):
ready_path = Path(manifest_path or Path(venv_dir).parent / "manifest.json")
if not venv_python(venv_dir).is_file() or not ready_path.is_file():
return False
try:
payload = json.loads(ready_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return False
return (
payload.get("environment_manifest_revision")
== ENVIRONMENT_MANIFEST_REVISION
)
return isinstance(payload, dict)
def installed_devices(args):
@@ -448,7 +445,7 @@ def installed_devices(args):
machine = platform.machine().lower()
if sys.platform == "darwin" and machine in {"arm64", "aarch64"}:
devices.append("mps")
elif args.backend.startswith("cu"):
elif args.torch_variant.startswith("cu"):
devices.append("cuda")
devices.append("cpu")
return devices
@@ -468,7 +465,7 @@ def run_verify(
stages=None,
stage_label=None,
):
"""Run the runtime import check with heartbeat progress; return the device report."""
"""Run the environment import check with heartbeat progress; return the device report."""
command = verify_command(python)
print("Running:", subprocess.list2cmdline([str(value) for value in command]))
process = subprocess.Popen(
@@ -509,7 +506,7 @@ def run_verify(
write_status(
status_path,
progress,
"Checking Backend Environment",
"Checking Environment",
stage=stage,
stages=stages,
stage_label=stage_label,
@@ -560,8 +557,8 @@ def bundled_uv_archive():
return path if path.is_file() else None
def sync_bundled_vendor(bundled_dir, vendor_dir):
"""Copy bundled third-party archives into persistent storage."""
def copy_vendor_inputs(bundled_dir, vendor_dir):
"""Copy bundled installation inputs into a temporary directory."""
for name in ("depth-anything-3.tar.gz", "ben2.tar.gz"):
bundled = Path(bundled_dir) / name
if not bundled.is_file():
@@ -573,6 +570,14 @@ def sync_bundled_vendor(bundled_dir, vendor_dir):
shutil.copy2(bundled, target)
def remove_legacy_vendor(storage_root):
"""Remove installation inputs persisted by older development builds."""
root = Path(storage_root)
shutil.rmtree(root / "vendor", ignore_errors=True)
for name in ("depth-anything-3.tar.gz", "ben2.tar.gz"):
(root / name).unlink(missing_ok=True)
def archive_is_valid(path):
if zipfile.is_zipfile(path):
return True
@@ -714,22 +719,28 @@ def install(args):
"shipping Intel Mac (x86_64) wheels after 2.2.2"
)
resolve_indexes(args)
runtime_dir = Path(args.runtime_dir)
runtime_dir.parent.mkdir(parents=True, exist_ok=True)
if not runtime_ready(runtime_dir):
shutil.rmtree(runtime_dir, ignore_errors=True)
venv_dir = Path(args.venv_dir)
manifest_path = Path(
getattr(args, "manifest", venv_dir.parent / "manifest.json")
)
venv_dir.parent.mkdir(parents=True, exist_ok=True)
if not env_installed(venv_dir, manifest_path):
shutil.rmtree(venv_dir, ignore_errors=True)
manifest_path.unlink(missing_ok=True)
uv = Path(args.uv)
if not uv.is_file():
resolved = shutil.which(args.uv)
uv = Path(resolved) if resolved else uv
uv_missing = not uv.is_file()
python = environment_python(runtime_dir)
sync_bundled_vendor(
python = venv_python(venv_dir)
vendor_temp = tempfile.TemporaryDirectory(prefix="snapform_vendor_")
vendor_dir = Path(vendor_temp.name)
copy_vendor_inputs(
bundled_vendor_directory(),
Path(args.runtime_dir).parent / "vendor",
vendor_dir,
)
stages = install_stages(args, runtime_dir, python, uv)
runtime_stages = [stage for stage in stages if stage[3] != "download"]
stages = install_stages(args, venv_dir, python, uv, vendor_dir)
env_stages = [stage for stage in stages if stage[3] != "download"]
model_stages = [stage for stage in stages if stage[3] == "download"]
model_span = (
(MODELS_SLICE_END - MODELS_SLICE_START) / len(model_stages)
@@ -737,13 +748,13 @@ def install(args):
else 0.0
)
installed_packages = []
component_stages = runtime_stages[2:]
component_stages = env_stages[2:]
component_ranges = (
[XFORMERS_SLICE, AI_COMPONENTS_SLICE]
if len(component_stages) == 2
else [(XFORMERS_SLICE[0], AI_COMPONENTS_SLICE[1])]
)
for index, (label, command, environment, kind) in enumerate(runtime_stages):
for index, (label, command, environment, kind) in enumerate(env_stages):
if index == 0:
stage = 1
stage_start, stage_end = ENVIRONMENT_SLICE
@@ -779,21 +790,21 @@ def install(args):
)
if isinstance(stage_installed, list):
installed_packages.extend(stage_installed)
vendor_temp.cleanup()
remove_legacy_vendor(venv_dir.parent)
devices = installed_devices(args)
runtime_dir.mkdir(parents=True, exist_ok=True)
ready_path = runtime_dir / ENVIRONMENT_MANIFEST_FILENAME
venv_dir.mkdir(parents=True, exist_ok=True)
manifest_path.parent.mkdir(parents=True, exist_ok=True)
ready_path = manifest_path
ready_path.write_text(
json.dumps(
{
"environment_manifest_revision": (
ENVIRONMENT_MANIFEST_REVISION
),
"python": "3.12",
"torch": TORCH_VERSION,
"xformers": XFORMERS_VERSION,
"fastapi": FASTAPI_VERSION,
"uvicorn": UVICORN_VERSION,
"backend": args.backend,
"torch_variant": args.torch_variant,
"devices": devices,
"da3_commit": args.da3_commit,
"ben2_commit": args.ben2_commit,
@@ -834,22 +845,23 @@ def install(args):
write_status(
args.status,
1.0,
f"Backend Environment is ready; retry {labels} from Models",
f"Environment is installed; retry {labels} from Models",
)
return
write_status(
args.status,
1.0,
"Backend Environment and recommended models ready",
"Environment and recommended models ready",
)
def parse_args():
parser = argparse.ArgumentParser()
parser.add_argument("--uv", required=True)
parser.add_argument("--runtime-dir", required=True)
parser.add_argument("--venv-dir", required=True)
parser.add_argument("--manifest", required=True)
parser.add_argument("--models-dir", required=True)
parser.add_argument("--backend", choices=tuple(TORCH_INDEXES), required=True)
parser.add_argument("--torch-variant", choices=tuple(TORCH_INDEXES), required=True)
parser.add_argument("--da3-commit", required=True)
parser.add_argument("--ben2-commit", required=True)
parser.add_argument("--status", required=True)
@@ -865,7 +877,7 @@ def main():
write_status(
args.status,
1.0,
"Backend Environment installation failed",
"Environment installation failed",
str(error),
)
raise

View File

@@ -83,7 +83,7 @@ _ENUM_ITEMS_CACHE = {}
def enum_items(supports_gs=None, show_download_status=False):
model_ready = None
if show_download_status:
from .runtime import model_ready
from .client import model_ready
models = [
model

View File

@@ -6,7 +6,7 @@ from pathlib import Path
import bpy
from . import runtime
from . import client
from .models import (
BEN2_MODEL,
get_downloadable_model,
@@ -95,7 +95,7 @@ class ActiveJob:
mesh_create_camera: bool = False
temporary_input_path: Path | None = None
cancelled: bool = False
uses_host: bool = False
uses_service: bool = False
started: bool = False
start_error: str = ""
start_thread: threading.Thread | None = None
@@ -114,9 +114,11 @@ def cancel_active_job():
_active_job.cancelled = True
if not getattr(_active_job, "started", True):
return
if _active_job.uses_host:
if _active_job.uses_service:
try:
runtime.cancel_host_job(_active_job.status_path.stem)
client.cancel_service_job(
client.job_id_from_status(_active_job.status_path)
)
except RuntimeError:
pass
elif _active_job.process is not None and _active_job.process.poll() is None:
@@ -150,7 +152,7 @@ class RunJob(bpy.types.Operator):
job_type: bpy.props.EnumProperty(
items=(
("INSTALL_RUNTIME", "Install Backend", ""),
("INSTALL_ENV", "Install Environment", ""),
("DOWNLOAD_MODEL", "Download Model", ""),
(RECONSTRUCTION_JOB, "Export DA3 Result", ""),
(IMAGE_DEPTH_JOB, "Convert Image to Depth Plane", ""),
@@ -219,10 +221,10 @@ class RunJob(bpy.types.Operator):
settings = context.scene.snapform_settings
try:
command, paths = self._prepare_command(context, settings)
uses_host = isinstance(command, runtime.HostJobRequest)
log_path = runtime.host_log_path() if uses_host else paths["log"]
working_directory = runtime.storage_root()
creation_flags = runtime.creation_flags()
uses_service = isinstance(command, client.JobRequest)
log_path = client.service_log_path() if uses_service else paths["log"]
working_directory = client.storage_root()
creation_flags = client.creation_flags()
except (OSError, RuntimeError, ValueError) as error:
self.report({"ERROR"}, str(error))
return {"CANCELLED"}
@@ -262,7 +264,7 @@ class RunJob(bpy.types.Operator):
and self.snapform_delete_input
else None
),
uses_host=uses_host,
uses_service=uses_service,
)
_active_job = job
settings.status = self._starting_message(settings)
@@ -292,11 +294,11 @@ class RunJob(bpy.types.Operator):
job.started = True
return
try:
if getattr(job, "uses_host", False):
runtime.submit_host_job(command)
if getattr(job, "uses_service", False):
client.submit_service_job(command)
else:
if job.kind == "INSTALL_RUNTIME":
runtime.stop_host()
if job.kind == "INSTALL_ENV":
client.stop_service()
job.log_file = job.log_path.open("w", encoding="utf-8")
job.process = subprocess.Popen(
command,
@@ -307,8 +309,10 @@ class RunJob(bpy.types.Operator):
)
job.started = True
if job.cancelled:
if job.uses_host:
runtime.cancel_host_job(job.status_path.stem)
if job.uses_service:
client.cancel_service_job(
client.job_id_from_status(job.status_path)
)
elif job.process is not None:
job.process.terminate()
except (OSError, RuntimeError) as error:
@@ -318,12 +322,12 @@ class RunJob(bpy.types.Operator):
job.start_error = str(error)
def _prepare_command(self, _context, settings):
paths = runtime.new_job_paths(self.job_type)
paths = client.new_job_paths(self.job_type)
if (
getattr(self, "snapform_delete_input", False)
and self.job_type in IMAGE_EMPTY_JOBS
):
archived_input = runtime.move_input_into_job(
archived_input = client.move_input_into_job(
self.snapform_input_path,
paths["result"],
)
@@ -332,18 +336,18 @@ class RunJob(bpy.types.Operator):
remove_background = bool(
getattr(self, "snapform_remove_background", False)
)
if self.job_type == "INSTALL_RUNTIME":
if self.job_type == "INSTALL_ENV":
self._require_online_access()
return runtime.install_runtime_command(paths["status"]), paths
return client.install_env_command(paths["status"]), paths
if not runtime.runtime_ready():
raise RuntimeError("Install the SnapForm Backend first")
if not client.env_installed():
raise RuntimeError("Install the SnapForm Environment first")
if self.job_type == "DOWNLOAD_MODEL":
self._require_online_access()
model = get_downloadable_model(self.model_key)
return (
runtime.download_model_command(model.key, paths["status"]),
client.download_model_command(model.key, paths["status"]),
paths,
)
@@ -372,10 +376,10 @@ class RunJob(bpy.types.Operator):
raise ValueError(
"BEN2 requires an image, video, or directory of image frames"
)
if not runtime.model_ready(BEN2_MODEL.key):
if not client.model_ready(BEN2_MODEL.key):
raise RuntimeError(f"Download {BEN2_MODEL.label} first")
return (
runtime.remove_background_request(
client.remove_background_request(
settings,
input_path,
paths["status"],
@@ -388,7 +392,7 @@ class RunJob(bpy.types.Operator):
)
if self.job_type == ORIGINAL_FRAMES_JOB:
return (
runtime.extract_frames_request(
client.extract_frames_request(
settings,
input_path,
paths["status"],
@@ -416,12 +420,12 @@ class RunJob(bpy.types.Operator):
use_ray_pose = self.snapform_use_ray_pose
process_res = self.snapform_process_res
model = get_model(model_key)
if not runtime.model_ready(model_key):
if not client.model_ready(model_key):
raise RuntimeError(f"Download {model.label} first")
if (
self.job_type in DA3_IMAGE_JOBS
and remove_background
and not runtime.model_ready(BEN2_MODEL.key)
and not client.model_ready(BEN2_MODEL.key)
):
raise RuntimeError(f"Download {BEN2_MODEL.label} first")
if export_format == "gs_ply" and not model.supports_gs:
@@ -429,7 +433,7 @@ class RunJob(bpy.types.Operator):
"PLY export requires a DA3 Giant or Nested Giant model"
)
return (
runtime.reconstruct_request(
client.reconstruct_request(
settings,
export_format,
paths["status"],
@@ -456,7 +460,7 @@ class RunJob(bpy.types.Operator):
def _task_device(self, settings):
"""Debug panels use the scene device; production tasks use Preferences."""
if self.job_type in PRODUCTION_AI_JOBS:
return runtime.production_device()
return client.production_device()
return settings.device
def _require_online_access(self):
@@ -467,7 +471,7 @@ class RunJob(bpy.types.Operator):
def _starting_message(self, settings):
labels = {
"INSTALL_RUNTIME": "Starting Backend installation",
"INSTALL_ENV": "Starting Environment installation",
"DOWNLOAD_MODEL": "Starting model download",
}
if self.job_type == RECONSTRUCTION_JOB:
@@ -516,14 +520,14 @@ class RunJob(bpy.types.Operator):
return {"CANCELLED"}
if not job.started:
return {"RUNNING_MODAL"}
if job.uses_host:
status = runtime.read_status(job.status_path) or {}
if job.uses_service:
status = client.read_status(job.status_path) or {}
state = status.get("state")
if state == "succeeded":
return self._finish(context, job, 0)
if state in {"failed", "cancelled"}:
return self._finish(context, job, 1)
if runtime.host_status().get("state") in {"ERROR", "STOPPED"}:
if client.service_status().get("state") in {"ERROR", "STOPPED"}:
return self._finish(context, job, 1)
return {"RUNNING_MODAL"}
return_code = job.process.poll()
@@ -532,7 +536,7 @@ class RunJob(bpy.types.Operator):
return self._finish(context, job, return_code)
def _update_status(self, context, job):
status = runtime.read_status(job.status_path)
status = client.read_status(job.status_path)
if status is None:
return
settings = context.scene.snapform_settings
@@ -593,28 +597,28 @@ class RunJob(bpy.types.Operator):
if job.cancelled:
settings.status = "Task cancelled"
settings.progress = 0.0
if getattr(job, "uses_host", False):
runtime.mark_host_job_complete(job.status_path.stem)
if getattr(job, "uses_service", False):
client.mark_service_job_complete(client.job_id_from_status(job.status_path))
close_active_job()
self._redraw_ui(context, force=True)
return {"CANCELLED"}
status = runtime.read_status(job.status_path) or {}
status = client.read_status(job.status_path) or {}
error = status.get("error")
if return_code != 0 or error:
settings.status = "Task failed"
settings.progress = 0.0
fallback_error = (
"Backend Service task failed"
if job.uses_host
"Service task failed"
if job.uses_service
else "Task subprocess failed"
)
self.report(
{"ERROR"},
f"{error or fallback_error}; log: {job.log_path}",
)
if job.uses_host:
runtime.mark_host_job_complete(job.status_path.stem)
if job.uses_service:
client.mark_service_job_complete(client.job_id_from_status(job.status_path))
close_active_job()
self._redraw_ui(context, force=True)
return {"CANCELLED"}
@@ -644,7 +648,7 @@ class RunJob(bpy.types.Operator):
settings.status = "Depth import failed"
settings.progress = 0.0
self.report({"ERROR"}, str(import_error))
runtime.mark_host_job_complete(job.status_path.stem)
client.mark_service_job_complete(client.job_id_from_status(job.status_path))
close_active_job()
self._redraw_ui(context, force=True)
return {"CANCELLED"}
@@ -659,7 +663,7 @@ class RunJob(bpy.types.Operator):
settings.status = "Surface import failed"
settings.progress = 0.0
self.report({"ERROR"}, str(import_error))
runtime.mark_host_job_complete(job.status_path.stem)
client.mark_service_job_complete(client.job_id_from_status(job.status_path))
close_active_job()
self._redraw_ui(context, force=True)
return {"CANCELLED"}
@@ -674,7 +678,7 @@ class RunJob(bpy.types.Operator):
settings.status = "Depth import failed"
settings.progress = 0.0
self.report({"ERROR"}, str(import_error))
runtime.mark_host_job_complete(job.status_path.stem)
client.mark_service_job_complete(client.job_id_from_status(job.status_path))
close_active_job()
self._redraw_ui(context, force=True)
return {"CANCELLED"}
@@ -689,7 +693,7 @@ class RunJob(bpy.types.Operator):
settings.status = "Image update failed"
settings.progress = 0.0
self.report({"ERROR"}, str(import_error))
runtime.mark_host_job_complete(job.status_path.stem)
client.mark_service_job_complete(client.job_id_from_status(job.status_path))
close_active_job()
self._redraw_ui(context, force=True)
return {"CANCELLED"}
@@ -701,8 +705,8 @@ class RunJob(bpy.types.Operator):
job.status_path.unlink(missing_ok=True)
except OSError:
pass
if getattr(job, "uses_host", False):
runtime.mark_host_job_complete(job.status_path.stem)
if getattr(job, "uses_service", False):
client.mark_service_job_complete(client.job_id_from_status(job.status_path))
close_active_job()
self._redraw_ui(context, force=True)
return {"FINISHED"}
@@ -836,17 +840,17 @@ class CancelJob(bpy.types.Operator):
return {"FINISHED"}
class ControlHost(bpy.types.Operator):
bl_idname = "snapform.control_host"
bl_label = "Control SnapForm Backend Service"
class ControlService(bpy.types.Operator):
bl_idname = "snapform.control_service"
bl_label = "Control SnapForm Service"
action: bpy.props.EnumProperty(
items=(
("START", "Start", "Start or connect to the Backend Service"),
("START", "Start", "Start or connect to the Service"),
("CLEAR", "Clear Models", "Release cached AI models"),
("RESTART", "Restart", "Restart the Backend Service"),
("STOP", "Stop", "Stop the Backend Service"),
("LOG", "Open Log", "Open the Backend Service log"),
("RESTART", "Restart", "Restart the Service"),
("STOP", "Stop", "Stop the Service"),
("LOG", "Open Log", "Open the Service log"),
),
options={"HIDDEN", "SKIP_SAVE"},
)
@@ -854,23 +858,23 @@ class ControlHost(bpy.types.Operator):
def execute(self, _context):
try:
if self.action == "START":
runtime.enable_host()
message = "Backend Service is ready"
client.enable_service()
message = "Service is ready"
elif self.action == "CLEAR":
runtime.clear_host_models()
client.clear_service_models()
message = "AI model cache is cleared"
elif self.action == "RESTART":
runtime.restart_host()
message = "Backend Service is restarting"
client.restart_service()
message = "Service is restarting"
elif self.action == "STOP":
runtime.stop_host()
message = "Backend Service is stopping"
client.stop_service()
message = "Service is stopping"
else:
log_path = runtime.host_log_path()
log_path = client.service_log_path()
target = log_path if log_path.is_file() else log_path.parent
result = bpy.ops.wm.path_open(filepath=str(target))
if "FINISHED" not in result:
raise RuntimeError("Unable to open the Backend Service log")
raise RuntimeError("Unable to open the Service log")
return {"FINISHED"}
except (OSError, RuntimeError) as error:
self.report({"ERROR"}, str(error))

View File

@@ -2,13 +2,13 @@ import bpy
from .models import BEN2_MODEL, MODELS, selected_model_key
from .operators import active_job
from . import runtime
from .runtime import model_ready, runtime_ready
from . import client
from .client import model_ready, env_installed
class HostPanel(bpy.types.Panel):
bl_label = "AI Backend"
bl_idname = "SNAPFORM_PT_host"
class AiServicePanel(bpy.types.Panel):
bl_label = "AI Service"
bl_idname = "SNAPFORM_PT_service"
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_category = "SnapForm"
@@ -16,23 +16,23 @@ class HostPanel(bpy.types.Panel):
def draw(self, _context):
layout = self.layout
running = active_job() is not None or runtime.host_busy()
if not runtime_ready():
running = active_job() is not None or client.service_busy()
if not env_installed():
layout.label(
text="Backend Environment is not installed",
text="Environment is not installed",
icon="ERROR",
)
install_row = layout.row()
install_row.enabled = not running
operator = install_row.operator(
"snapform.run_job",
text="Install Backend",
text="Install Environment",
icon="IMPORT",
)
operator.job_type = "INSTALL_RUNTIME"
operator.job_type = "INSTALL_ENV"
return
status = runtime.host_status()
status = client.service_status()
state = status.get("state", "STOPPED")
icons = {
"READY": "RADIOBUT_ON",
@@ -56,20 +56,20 @@ class HostPanel(bpy.types.Panel):
controls = row.row(align=True)
controls.enabled = status.get("clients", 0) <= 1
operator = controls.operator(
"snapform.control_host",
"snapform.control_service",
text="",
icon="FILE_REFRESH",
)
operator.action = "RESTART"
operator = controls.operator(
"snapform.control_host",
"snapform.control_service",
text="",
icon="PAUSE",
)
operator.action = "STOP"
elif state == "STOPPED":
operator = row.operator(
"snapform.control_host",
"snapform.control_service",
text="Start",
icon="PLAY",
)
@@ -81,7 +81,7 @@ class HostPanel(bpy.types.Panel):
)
if state == "ERROR":
operator = row.operator(
"snapform.control_host",
"snapform.control_service",
text="Start",
icon="PLAY",
)
@@ -101,7 +101,7 @@ class HostPanel(bpy.types.Panel):
clear = cached_row.row(align=True)
clear.enabled = state == "READY"
operator = clear.operator(
"snapform.control_host",
"snapform.control_service",
text="",
icon="TRASH",
)
@@ -111,20 +111,19 @@ class HostPanel(bpy.types.Panel):
class ModelsPanel(bpy.types.Panel):
bl_label = "Models"
bl_idname = "SNAPFORM_PT_models"
bl_parent_id = HostPanel.bl_idname
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_category = "SnapForm"
bl_options = {"DEFAULT_CLOSED"}
bl_order = 0
bl_order = 1
@classmethod
def poll(cls, _context):
return runtime_ready()
return env_installed()
def draw(self, _context):
layout = self.layout
running = active_job() is not None or runtime.host_busy()
running = active_job() is not None or client.service_busy()
for model in MODELS.values():
self._draw_model(layout, model, running)
@@ -137,7 +136,7 @@ class ModelsPanel(bpy.types.Panel):
row.label(text="Ready", icon="CHECKMARK")
else:
download_row = row.row(align=True)
download_row.enabled = runtime_ready() and not running
download_row.enabled = env_installed() and not running
operator = download_row.operator(
"snapform.run_job",
text="Download",
@@ -154,7 +153,7 @@ class DebugPanel(bpy.types.Panel):
bl_region_type = "UI"
bl_category = "SnapForm"
bl_options = {"DEFAULT_CLOSED"}
bl_order = 1
bl_order = 2
def draw(self, context):
layout = self.layout
@@ -226,7 +225,7 @@ class Da3OutputDebugPanel(bpy.types.Panel):
export_row = layout.row()
export_row.enabled = (
active_job() is None
and not runtime.host_busy()
and not client.service_busy()
and model_ready(selected_model_key(settings))
)
operator = export_row.operator(
@@ -245,7 +244,7 @@ class Ben2RemovalDebugPanel(bpy.types.Panel):
bl_region_type = "UI"
bl_category = "SnapForm"
bl_options = {"DEFAULT_CLOSED"}
bl_order = 1
bl_order = 2
def draw(self, context):
layout = self.layout
@@ -255,7 +254,7 @@ class Ben2RemovalDebugPanel(bpy.types.Panel):
background_row = layout.row()
background_row.enabled = (
active_job() is None
and not runtime.host_busy()
and not client.service_busy()
and model_ready(BEN2_MODEL.key)
)
operator = background_row.operator(

View File

@@ -2,7 +2,7 @@ from pathlib import Path
import bpy
from .runtime import device_enum_items
from .client import device_enum_items
class SnapFormPreferences(bpy.types.AddonPreferences):
@@ -11,7 +11,7 @@ class SnapFormPreferences(bpy.types.AddonPreferences):
storage_root: bpy.props.StringProperty(
name="Storage Root",
description=(
"Shared user-writable storage for Backend Environment, models and jobs; "
"Shared user-writable storage for the environment, models and jobs; "
"all Blender versions can use the same directory"
),
subtype="DIR_PATH",
@@ -29,27 +29,27 @@ class SnapFormPreferences(bpy.types.AddonPreferences):
def draw(self, _context):
from .operators import active_job
from .runtime import runtime_directory, runtime_ready
from .client import venv_directory, env_installed
layout = self.layout
layout.prop(self, "storage_root")
layout.prop(self, "device")
layout.separator()
layout.label(text=f"Backend Environment: {runtime_directory()}")
if runtime_ready():
layout.label(text="Backend Environment is ready", icon="CHECKMARK")
layout.label(text=f"Environment: {venv_directory()}")
if env_installed():
layout.label(text="Environment is installed", icon="CHECKMARK")
else:
install_row = layout.row()
install_row.enabled = active_job() is None
operator = install_row.operator(
"snapform.run_job",
text="Install Backend",
text="Install Environment",
icon="IMPORT",
)
operator.job_type = "INSTALL_RUNTIME"
operator.job_type = "INSTALL_ENV"
layout.separator()
operator = layout.operator(
"snapform.control_host",
"snapform.control_service",
text="Open Service Log",
icon="TEXT",
)

View File

@@ -1,7 +1,7 @@
import bpy
from .models import gs_model_enum_items, model_enum_items
from .runtime import device_enum_items
from .client import device_enum_items
class SnapFormSettings(bpy.types.PropertyGroup):

View File

@@ -19,12 +19,11 @@ if str(SCRIPT_DIRECTORY) not in sys.path:
import worker
JOB_PROTOCOL_VERSION = 2
CLIENT_TIMEOUT_SECONDS = 10.0
IDLE_SHUTDOWN_SECONDS = 20.0
class HostLock:
class ServiceLock:
def __init__(self, path):
self.path = Path(path)
self.file = None
@@ -159,7 +158,7 @@ class ModelCache:
self.torch.cuda.empty_cache()
class HostState:
class ServiceState:
def __init__(self, token, instance_id):
self.token = token
self.instance_id = instance_id
@@ -212,7 +211,6 @@ class HostState:
with self.lock:
return {
"ready": True,
"job_protocol_version": JOB_PROTOCOL_VERSION,
"instance_id": self.instance_id,
"busy": self.active_job is not None,
"active_job": dict(self.active_job) if self.active_job else None,
@@ -265,7 +263,7 @@ class HostState:
worker.write_status(
status_path,
0.0,
"Backend Service accepted the task",
"Service accepted the task",
state="running",
)
try:
@@ -318,7 +316,7 @@ class HostState:
def start_monitor(self):
self.monitor_thread = threading.Thread(
target=self._monitor,
name="SnapFormHostMonitor",
name="SnapFormServiceMonitor",
daemon=True,
)
self.monitor_thread.start()
@@ -363,7 +361,7 @@ class HostState:
def create_app(token, instance_id="test"):
from fastapi import BackgroundTasks, FastAPI, Header, HTTPException
state = HostState(token, instance_id)
state = ServiceState(token, instance_id)
@asynccontextmanager
async def lifespan(_app):
@@ -372,17 +370,17 @@ def create_app(token, instance_id="test"):
state.close()
app = FastAPI(
title="SnapForm Backend Service",
title="SnapForm Service",
docs_url=None,
redoc_url=None,
openapi_url=None,
lifespan=lifespan,
)
app.state.snapform_host_state = state
app.state.snapform_service_state = state
def authorize(x_snapform_token: str = Header(default="")):
if not hmac.compare_digest(x_snapform_token, state.token):
raise HTTPException(status_code=401, detail="Invalid host token")
raise HTTPException(status_code=401, detail="Invalid service token")
@app.get("/health")
def health(x_snapform_token: str = Header(default="")):
@@ -445,7 +443,7 @@ def create_app(token, instance_id="test"):
command,
parameters["status"],
):
raise HTTPException(status_code=409, detail="Backend Service is busy")
raise HTTPException(status_code=409, detail="Service is busy")
worker.write_status(
parameters["status"],
0.0,
@@ -485,11 +483,11 @@ def create_app(token, instance_id="test"):
def clear_models(x_snapform_token: str = Header(default="")):
authorize(x_snapform_token)
if not state.clear_models():
raise HTTPException(status_code=409, detail="Backend Service is busy")
raise HTTPException(status_code=409, detail="Service is busy")
return {"state": "cleared"}
@app.post("/shutdown", status_code=202)
def shutdown_host(
def shutdown_service(
payload: dict,
x_snapform_token: str = Header(default=""),
):
@@ -498,7 +496,7 @@ def create_app(token, instance_id="test"):
if result == "shared":
raise HTTPException(status_code=409, detail="Other Blender clients are connected")
if result == "busy":
raise HTTPException(status_code=409, detail="Backend Service is busy")
raise HTTPException(status_code=409, detail="Service is busy")
if result != "accepted":
raise HTTPException(status_code=404, detail="Client is not registered")
return {"state": "stopping"}
@@ -524,7 +522,6 @@ def write_discovery(args):
temporary.write_text(
json.dumps(
{
"job_protocol_version": JOB_PROTOCOL_VERSION,
"port": args.port,
"token": args.token,
"pid": os.getpid(),
@@ -551,8 +548,8 @@ def main():
import uvicorn
args = parse_args()
host_lock = HostLock(args.lock)
if not host_lock.acquire():
service_lock = ServiceLock(args.lock)
if not service_lock.acquire():
return
try:
app = create_app(args.token, args.instance_id)
@@ -566,7 +563,7 @@ def main():
server = uvicorn.Server(config)
def watch_shutdown():
app.state.snapform_host_state.shutdown_event.wait()
app.state.snapform_service_state.shutdown_event.wait()
server.should_exit = True
threading.Thread(target=watch_shutdown, daemon=True).start()
@@ -574,7 +571,7 @@ def main():
server.run()
finally:
remove_discovery(args.discovery, args.instance_id)
host_lock.release()
service_lock.release()
if __name__ == "__main__":

View File

@@ -1282,7 +1282,7 @@ def infer_foreground_frames(
def remove_background(args, model_cache=None, cancel_check=None):
total_started = time.perf_counter()
write_status(args.status, 0.02, "Starting Backend Environment")
write_status(args.status, 0.02, "Starting Environment")
check_cancelled(cancel_check)
if model_cache is None:
@@ -1501,7 +1501,7 @@ def flatten_exported_paths(
def reconstruct(args, model_cache=None, cancel_check=None):
write_status(args.status, 0.02, "Starting Backend Environment")
write_status(args.status, 0.02, "Starting Environment")
check_cancelled(cancel_check)
if model_cache is None:

File diff suppressed because it is too large Load Diff

View File

@@ -16,17 +16,17 @@ from unittest.mock import patch
PROJECT_ROOT = Path(__file__).resolve().parents[1]
def load_bootstrap_runtime():
def load_install_env():
specification = importlib.util.spec_from_file_location(
"snapform_bootstrap_runtime",
PROJECT_ROOT / "src" / "snapform" / "bootstrap_runtime.py",
"snapform_install_env",
PROJECT_ROOT / "src" / "snapform" / "install_env.py",
)
module = importlib.util.module_from_spec(specification)
specification.loader.exec_module(module)
return module
class BootstrapRuntimeTest(unittest.TestCase):
class InstallEnvTest(unittest.TestCase):
def create_bundled_vendor(self, root):
vendor = root / "vendor"
vendor.mkdir()
@@ -35,23 +35,19 @@ class BootstrapRuntimeTest(unittest.TestCase):
return vendor
def test_installs_da3_from_archives_without_gsplat(self):
bootstrap = load_bootstrap_runtime()
bootstrap = load_install_env()
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
runtime_dir = root / "runtime"
runtime_dir.mkdir()
venv_dir = root / ".venv"
venv_dir.mkdir()
self.create_bundled_vendor(root)
da3_uri = (
root / "vendor" / "depth-anything-3.tar.gz"
).as_uri()
ben2_uri = (root / "vendor" / "ben2.tar.gz").as_uri()
uv_path = root / "uv"
uv_path.write_bytes(b"uv")
args = SimpleNamespace(
uv=str(uv_path),
runtime_dir=str(runtime_dir),
venv_dir=str(venv_dir),
models_dir=str(root / "models"),
backend="cu128",
torch_variant="cu128",
da3_commit="test-commit",
ben2_commit="test-ben2-commit",
status=str(root / "status.json"),
@@ -82,7 +78,13 @@ class BootstrapRuntimeTest(unittest.TestCase):
f"addict=={bootstrap.ADDICT_VERSION}",
da3_command,
)
self.assertIn(f"depth-anything-3 @ {da3_uri}", da3_command)
self.assertTrue(
any(
"depth-anything-3 @ file:" in str(value)
and "snapform_vendor_" in str(value)
for value in da3_command
)
)
self.assertFalse(
any("git+" in str(value) for value in da3_command)
)
@@ -105,20 +107,26 @@ class BootstrapRuntimeTest(unittest.TestCase):
for command in commands
if any("ben2 @" in str(value) for value in command)
)
self.assertIn(f"ben2 @ {ben2_uri}", ben2_command)
self.assertTrue(
any(
"ben2 @ file:" in str(value)
and "snapform_vendor_" in str(value)
for value in ben2_command
)
)
self.assertFalse(
any("git+" in str(value) for value in ben2_command)
)
host_command = next(
service_command = next(
command
for command in commands
if f"fastapi=={bootstrap.FASTAPI_VERSION}" in command
)
self.assertIn(f"uvicorn=={bootstrap.UVICORN_VERSION}", host_command)
self.assertIn("setuptools", host_command)
self.assertNotIn("ninja", host_command)
self.assertIn(f"uvicorn=={bootstrap.UVICORN_VERSION}", service_command)
self.assertIn("setuptools", service_command)
self.assertNotIn("ninja", service_command)
check_command = bootstrap.verify_command(
bootstrap.environment_python(runtime_dir)
bootstrap.venv_python(venv_dir)
)
self.assertTrue(
any("import torch;" in str(value) for value in check_command)
@@ -179,19 +187,19 @@ class BootstrapRuntimeTest(unittest.TestCase):
)
def test_darwin_install_skips_cuda_index_and_xformers(self):
bootstrap = load_bootstrap_runtime()
bootstrap = load_install_env()
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
runtime_dir = root / "runtime"
runtime_dir.mkdir()
venv_dir = root / ".venv"
venv_dir.mkdir()
self.create_bundled_vendor(root)
uv_path = root / "uv"
uv_path.write_bytes(b"uv")
args = SimpleNamespace(
uv=str(uv_path),
runtime_dir=str(runtime_dir),
venv_dir=str(venv_dir),
models_dir=str(root / "models"),
backend="cu128",
torch_variant="cu128",
da3_commit="test-commit",
ben2_commit="test-ben2-commit",
status=str(root / "status.json"),
@@ -238,12 +246,12 @@ class BootstrapRuntimeTest(unittest.TestCase):
)
def test_intel_mac_is_rejected_with_clear_error(self):
bootstrap = load_bootstrap_runtime()
bootstrap = load_install_env()
args = SimpleNamespace(
uv="uv",
runtime_dir="runtime",
venv_dir=".venv",
models_dir="models",
backend="cu128",
torch_variant="cu128",
da3_commit="test-commit",
ben2_commit="test-ben2-commit",
status="status.json",
@@ -263,7 +271,7 @@ class BootstrapRuntimeTest(unittest.TestCase):
mocked_rmtree.assert_not_called()
def test_run_stage_writes_stage_progress(self):
bootstrap = load_bootstrap_runtime()
bootstrap = load_install_env()
with tempfile.TemporaryDirectory() as directory:
status_path = Path(directory) / "status.json"
with patch.object(bootstrap, "run") as mocked_run:
@@ -288,7 +296,7 @@ class BootstrapRuntimeTest(unittest.TestCase):
self.assertEqual(status["stage_label"], "pytorch")
def test_run_stage_writes_progress_range(self):
bootstrap = load_bootstrap_runtime()
bootstrap = load_install_env()
with tempfile.TemporaryDirectory() as directory:
status_path = Path(directory) / "status.json"
with patch.object(bootstrap, "run"), patch.object(
@@ -314,7 +322,7 @@ class BootstrapRuntimeTest(unittest.TestCase):
self.assertEqual(progresses[-1], 0.4)
def test_run_stage_passes_progress_range_to_download_command(self):
bootstrap = load_bootstrap_runtime()
bootstrap = load_install_env()
with tempfile.TemporaryDirectory() as directory:
status_path = Path(directory) / "status.json"
command = [
@@ -351,19 +359,19 @@ class BootstrapRuntimeTest(unittest.TestCase):
self.assertEqual(called_command[-1], "da3 small")
def test_install_allocates_contiguous_progress_slices(self):
bootstrap = load_bootstrap_runtime()
bootstrap = load_install_env()
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
runtime_dir = root / "runtime"
runtime_dir.mkdir()
venv_dir = root / ".venv"
venv_dir.mkdir()
self.create_bundled_vendor(root)
uv_path = root / "uv"
uv_path.write_bytes(b"uv")
args = SimpleNamespace(
uv=str(uv_path),
runtime_dir=str(runtime_dir),
venv_dir=str(venv_dir),
models_dir=str(root / "models"),
backend="cu128",
torch_variant="cu128",
da3_commit="test-commit",
ben2_commit="test-ben2-commit",
status=str(root / "status.json"),
@@ -424,7 +432,7 @@ class BootstrapRuntimeTest(unittest.TestCase):
self.assertEqual(status["progress"], 1.0)
def test_run_install_tracks_package_download_events(self):
bootstrap = load_bootstrap_runtime()
bootstrap = load_install_env()
class FakeProcess:
def __init__(self, lines):
@@ -474,7 +482,7 @@ class BootstrapRuntimeTest(unittest.TestCase):
self.assertEqual(installed, ["torch==2.9.1", "numpy==1.26.4"])
def test_run_install_smooths_progress_between_events(self):
bootstrap = load_bootstrap_runtime()
bootstrap = load_install_env()
class SlowLines:
def __init__(self, lines, delay):
@@ -529,7 +537,7 @@ class BootstrapRuntimeTest(unittest.TestCase):
self.assertIn("Downloading packages 3/3", messages)
def test_run_install_maps_progress_into_stage_range(self):
bootstrap = load_bootstrap_runtime()
bootstrap = load_install_env()
class FakeProcess:
def __init__(self, lines):
@@ -567,7 +575,7 @@ class BootstrapRuntimeTest(unittest.TestCase):
self.assertEqual(first.kwargs["stage_label"], "pytorch deps")
def test_run_install_retries_once_with_mirror(self):
bootstrap = load_bootstrap_runtime()
bootstrap = load_install_env()
previous = bootstrap._resolved_pypi_index
bootstrap._resolved_pypi_index = None
try:
@@ -597,13 +605,13 @@ class BootstrapRuntimeTest(unittest.TestCase):
)
def test_formats_elapsed_time(self):
bootstrap = load_bootstrap_runtime()
bootstrap = load_install_env()
self.assertEqual(bootstrap.format_elapsed(12), "12s")
self.assertEqual(bootstrap.format_elapsed(83), "1m 23s")
def test_ai_component_estimate_continues_across_internal_steps(self):
bootstrap = load_bootstrap_runtime()
bootstrap = load_install_env()
first = bootstrap.estimated_remaining(
"AI components",
@@ -619,7 +627,7 @@ class BootstrapRuntimeTest(unittest.TestCase):
self.assertLess(second, first)
def test_resolve_indexes_uses_configured_pypi_index(self):
bootstrap = load_bootstrap_runtime()
bootstrap = load_install_env()
bootstrap.resolve_indexes(
SimpleNamespace(pypi_index="https://pypi.tuna.tsinghua.edu.cn/simple")
)
@@ -629,7 +637,7 @@ class BootstrapRuntimeTest(unittest.TestCase):
)
def test_resolve_indexes_uses_environment_override(self):
bootstrap = load_bootstrap_runtime()
bootstrap = load_install_env()
with patch.dict(
os.environ,
{"PIP_INDEX_URL": "https://pypi.example/simple"},
@@ -641,7 +649,7 @@ class BootstrapRuntimeTest(unittest.TestCase):
)
def test_child_environment_injects_resolved_pypi_index(self):
bootstrap = load_bootstrap_runtime()
bootstrap = load_install_env()
previous_pypi = bootstrap._resolved_pypi_index
bootstrap._resolved_pypi_index = (
"https://pypi.tuna.tsinghua.edu.cn/simple"
@@ -657,19 +665,19 @@ class BootstrapRuntimeTest(unittest.TestCase):
)
def test_ready_json_records_installed_packages(self):
bootstrap = load_bootstrap_runtime()
bootstrap = load_install_env()
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
runtime_dir = root / "runtime"
runtime_dir.mkdir()
venv_dir = root / ".venv"
venv_dir.mkdir()
self.create_bundled_vendor(root)
uv_path = root / "uv"
uv_path.write_bytes(b"uv")
args = SimpleNamespace(
uv=str(uv_path),
runtime_dir=str(runtime_dir),
venv_dir=str(venv_dir),
models_dir=str(root / "models"),
backend="cu128",
torch_variant="cu128",
da3_commit="test-commit",
ben2_commit="test-ben2-commit",
status=str(root / "status.json"),
@@ -689,7 +697,7 @@ class BootstrapRuntimeTest(unittest.TestCase):
bootstrap.install(args)
ready = json.loads(
(runtime_dir / "environment_manifest.json").read_text(
(root / "manifest.json").read_text(
encoding="utf-8"
)
)
@@ -699,19 +707,19 @@ class BootstrapRuntimeTest(unittest.TestCase):
)
self.assertEqual(ready["devices"], ["cuda", "cpu"])
def test_broken_runtime_is_cleaned_before_reinstall(self):
bootstrap = load_bootstrap_runtime()
def test_broken_env_is_cleaned_before_reinstall(self):
bootstrap = load_install_env()
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
runtime_dir = root / "runtime"
runtime_dir.mkdir()
venv_dir = root / ".venv"
venv_dir.mkdir()
self.create_bundled_vendor(root)
(runtime_dir / "partial-python.exe").write_text("junk")
(venv_dir / "partial-python.exe").write_text("junk")
args = SimpleNamespace(
uv="uv",
runtime_dir=str(runtime_dir),
venv_dir=str(venv_dir),
models_dir=str(root / "models"),
backend="cu128",
torch_variant="cu128",
da3_commit="test-commit",
ben2_commit="test-ben2-commit",
status=str(root / "status.json"),
@@ -729,36 +737,30 @@ class BootstrapRuntimeTest(unittest.TestCase):
):
bootstrap.install(args)
self.assertFalse((runtime_dir / "partial-python.exe").exists())
self.assertFalse((venv_dir / "partial-python.exe").exists())
self.assertTrue(
(runtime_dir / "environment_manifest.json").is_file()
(root / "manifest.json").is_file()
)
def test_ready_runtime_is_not_cleaned(self):
bootstrap = load_bootstrap_runtime()
def test_installed_env_is_not_cleaned(self):
bootstrap = load_install_env()
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
runtime_dir = root / "runtime"
runtime_dir.mkdir(parents=True)
venv_dir = root / ".venv"
venv_dir.mkdir(parents=True)
self.create_bundled_vendor(root)
(runtime_dir / "Scripts").mkdir()
(runtime_dir / "Scripts" / "python.exe").write_text("python")
(runtime_dir / "environment_manifest.json").write_text(
json.dumps(
{
"environment_manifest_revision": (
bootstrap.ENVIRONMENT_MANIFEST_REVISION
)
}
),
(venv_dir / "Scripts").mkdir()
(venv_dir / "Scripts" / "python.exe").write_text("python")
(root / "manifest.json").write_text(
json.dumps({}),
encoding="utf-8",
)
(runtime_dir / "marker.txt").write_text("keep")
(venv_dir / "marker.txt").write_text("keep")
args = SimpleNamespace(
uv="uv",
runtime_dir=str(runtime_dir),
venv_dir=str(venv_dir),
models_dir=str(root / "models"),
backend="cu128",
torch_variant="cu128",
da3_commit="test-commit",
ben2_commit="test-ben2-commit",
status=str(root / "status.json"),
@@ -776,10 +778,10 @@ class BootstrapRuntimeTest(unittest.TestCase):
):
bootstrap.install(args)
self.assertTrue((runtime_dir / "marker.txt").is_file())
self.assertTrue((venv_dir / "marker.txt").is_file())
def test_model_download_failure_keeps_runtime_ready(self):
bootstrap = load_bootstrap_runtime()
def test_model_download_failure_keeps_env_installed(self):
bootstrap = load_install_env()
commands = []
def fake_run(command, environment=None):
@@ -789,14 +791,14 @@ class BootstrapRuntimeTest(unittest.TestCase):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
runtime_dir = root / "runtime"
runtime_dir.mkdir()
venv_dir = root / ".venv"
venv_dir.mkdir()
self.create_bundled_vendor(root)
args = SimpleNamespace(
uv="uv",
runtime_dir=str(runtime_dir),
venv_dir=str(venv_dir),
models_dir=str(root / "models"),
backend="cu128",
torch_variant="cu128",
da3_commit="test-commit",
ben2_commit="test-ben2-commit",
status=str(root / "status.json"),
@@ -813,13 +815,13 @@ class BootstrapRuntimeTest(unittest.TestCase):
bootstrap.install(args)
self.assertTrue(
(runtime_dir / "environment_manifest.json").is_file()
(root / "manifest.json").is_file()
)
status = json.loads(
(root / "status.json").read_text(encoding="utf-8")
)
self.assertEqual(status["progress"], 1.0)
self.assertIn("Backend Environment is ready", status["message"])
self.assertIn("Environment is installed", status["message"])
self.assertTrue(
any(
"PramaLLC/BEN2" in map(str, command)
@@ -828,18 +830,18 @@ class BootstrapRuntimeTest(unittest.TestCase):
)
def test_install_fails_when_bundled_uv_missing(self):
bootstrap = load_bootstrap_runtime()
bootstrap = load_install_env()
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
runtime_dir = root / "runtime"
runtime_dir.mkdir()
venv_dir = root / ".venv"
venv_dir.mkdir()
self.create_bundled_vendor(root)
uv_path = root / "uv" / "uv.exe"
args = SimpleNamespace(
uv=str(uv_path),
runtime_dir=str(runtime_dir),
venv_dir=str(venv_dir),
models_dir=str(root / "models"),
backend="cu128",
torch_variant="cu128",
da3_commit="test-commit",
ben2_commit="test-ben2-commit",
status=str(root / "status.json"),
@@ -869,11 +871,11 @@ class BootstrapRuntimeTest(unittest.TestCase):
bootstrap.install(args)
def test_install_uses_bundled_vendor_and_uv_without_network(self):
bootstrap = load_bootstrap_runtime()
bootstrap = load_install_env()
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
runtime_dir = root / "runtime"
runtime_dir.mkdir()
venv_dir = root / ".venv"
venv_dir.mkdir()
bundled = root / "bundled"
bundled.mkdir()
(bundled / "depth-anything-3.tar.gz").write_bytes(b"da3")
@@ -892,9 +894,9 @@ class BootstrapRuntimeTest(unittest.TestCase):
uv_path = root / "uv" / ("uv.exe" if os.name == "nt" else "uv")
args = SimpleNamespace(
uv=str(uv_path),
runtime_dir=str(runtime_dir),
venv_dir=str(venv_dir),
models_dir=str(root / "models"),
backend="cu128",
torch_variant="cu128",
da3_commit="test-commit",
ben2_commit="test-ben2-commit",
status=str(root / "status.json"),
@@ -956,25 +958,35 @@ class BootstrapRuntimeTest(unittest.TestCase):
for command in install_commands
if any("depth-anything-3 @" in str(value) for value in command)
)
da3_uri = (root / "vendor" / "depth-anything-3.tar.gz").as_uri()
ben2_uri = (root / "vendor" / "ben2.tar.gz").as_uri()
self.assertIn(f"depth-anything-3 @ {da3_uri}", ai_command)
self.assertIn(f"ben2 @ {ben2_uri}", ai_command)
self.assertTrue(
any(
"depth-anything-3 @ file:" in str(value)
and "snapform_vendor_" in str(value)
for value in ai_command
)
)
self.assertTrue(
any(
"ben2 @ file:" in str(value)
and "snapform_vendor_" in str(value)
for value in ai_command
)
)
def test_install_reuses_existing_uv(self):
bootstrap = load_bootstrap_runtime()
bootstrap = load_install_env()
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
runtime_dir = root / "runtime"
runtime_dir.mkdir()
venv_dir = root / ".venv"
venv_dir.mkdir()
self.create_bundled_vendor(root)
uv_path = root / "uv.exe"
uv_path.write_bytes(b"uv")
args = SimpleNamespace(
uv=str(uv_path),
runtime_dir=str(runtime_dir),
venv_dir=str(venv_dir),
models_dir=str(root / "models"),
backend="cu128",
torch_variant="cu128",
da3_commit="test-commit",
ben2_commit="test-ben2-commit",
status=str(root / "status.json"),
@@ -993,7 +1005,7 @@ class BootstrapRuntimeTest(unittest.TestCase):
bootstrap.install(args)
def test_extract_uv_unpacks_standalone_binary(self):
bootstrap = load_bootstrap_runtime()
bootstrap = load_install_env()
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
archive_path = root / "uv.zip"
@@ -1005,8 +1017,8 @@ class BootstrapRuntimeTest(unittest.TestCase):
self.assertEqual(uv_path.read_bytes(), b"binary")
def test_sync_bundled_vendor_copies_missing_archives(self):
bootstrap = load_bootstrap_runtime()
def test_copy_vendor_inputs_copies_missing_archives(self):
bootstrap = load_install_env()
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
bundled = root / "bundled"
@@ -1015,7 +1027,7 @@ class BootstrapRuntimeTest(unittest.TestCase):
(bundled / "ben2.tar.gz").write_bytes(b"ben2")
vendor = root / "vendor"
bootstrap.sync_bundled_vendor(bundled, vendor)
bootstrap.copy_vendor_inputs(bundled, vendor)
self.assertEqual(
(vendor / "depth-anything-3.tar.gz").read_bytes(),
@@ -1026,8 +1038,8 @@ class BootstrapRuntimeTest(unittest.TestCase):
b"ben2",
)
def test_sync_bundled_vendor_replaces_same_size_stale_archive(self):
bootstrap = load_bootstrap_runtime()
def test_copy_vendor_inputs_replaces_same_size_stale_archive(self):
bootstrap = load_install_env()
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
bundled = root / "bundled"
@@ -1038,19 +1050,38 @@ class BootstrapRuntimeTest(unittest.TestCase):
vendor.mkdir()
(vendor / "depth-anything-3.tar.gz").write_bytes(b"old")
bootstrap.sync_bundled_vendor(bundled, vendor)
bootstrap.copy_vendor_inputs(bundled, vendor)
self.assertEqual(
(vendor / "depth-anything-3.tar.gz").read_bytes(),
b"new",
)
def test_install_stages_uses_local_source_archives(self):
bootstrap = load_bootstrap_runtime()
def test_remove_legacy_vendor_removes_only_install_inputs(self):
bootstrap = load_install_env()
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
runtime_dir = root / "runtime"
runtime_dir.mkdir()
vendor = root / "vendor"
vendor.mkdir()
(vendor / "depth-anything-3.tar.gz").write_bytes(b"da3")
(root / "depth-anything-3.tar.gz").write_bytes(b"da3")
(root / "ben2.tar.gz").write_bytes(b"ben2")
models = root / "models"
models.mkdir()
bootstrap.remove_legacy_vendor(root)
self.assertFalse(vendor.exists())
self.assertFalse((root / "depth-anything-3.tar.gz").exists())
self.assertFalse((root / "ben2.tar.gz").exists())
self.assertTrue(models.is_dir())
def test_install_stages_uses_local_source_archives(self):
bootstrap = load_install_env()
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
venv_dir = root / ".venv"
venv_dir.mkdir()
vendor = root / "vendor"
vendor.mkdir()
(vendor / "depth-anything-3.tar.gz").write_bytes(b"da3")
@@ -1059,9 +1090,9 @@ class BootstrapRuntimeTest(unittest.TestCase):
uv_path.write_bytes(b"uv")
args = SimpleNamespace(
uv=str(uv_path),
runtime_dir=str(runtime_dir),
venv_dir=str(venv_dir),
models_dir=str(root / "models"),
backend="cu128",
torch_variant="cu128",
da3_commit="test-commit",
ben2_commit="test-ben2-commit",
status=str(root / "status.json"),
@@ -1069,7 +1100,7 @@ class BootstrapRuntimeTest(unittest.TestCase):
stages = bootstrap.install_stages(
args,
runtime_dir,
venv_dir,
root / "python",
uv_path,
)
@@ -1086,11 +1117,11 @@ class BootstrapRuntimeTest(unittest.TestCase):
self.assertIn(f"ben2 @ {ben2_local}", ai_command)
def test_install_stages_download_only_default_models(self):
bootstrap = load_bootstrap_runtime()
bootstrap = load_install_env()
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
runtime_dir = root / "runtime"
runtime_dir.mkdir()
venv_dir = root / ".venv"
venv_dir.mkdir()
vendor = root / "vendor"
vendor.mkdir()
(vendor / "depth-anything-3.tar.gz").write_bytes(b"da3")
@@ -1099,9 +1130,9 @@ class BootstrapRuntimeTest(unittest.TestCase):
uv_path.write_bytes(b"uv")
args = SimpleNamespace(
uv=str(uv_path),
runtime_dir=str(runtime_dir),
venv_dir=str(venv_dir),
models_dir=str(root / "models"),
backend="cu128",
torch_variant="cu128",
da3_commit="test-commit",
ben2_commit="test-ben2-commit",
status=str(root / "status.json"),
@@ -1109,7 +1140,7 @@ class BootstrapRuntimeTest(unittest.TestCase):
stages = bootstrap.install_stages(
args,
runtime_dir,
venv_dir,
root / "python",
uv_path,
)
@@ -1123,20 +1154,20 @@ class BootstrapRuntimeTest(unittest.TestCase):
self.assertIn("ben2_base", " ".join(map(str, downloads["BEN2 Base"])))
self.assertNotIn("DA3 Base", downloads)
def test_install_does_not_verify_runtime(self):
bootstrap = load_bootstrap_runtime()
def test_install_does_not_verify_env(self):
bootstrap = load_install_env()
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
runtime_dir = root / "runtime"
runtime_dir.mkdir()
venv_dir = root / ".venv"
venv_dir.mkdir()
self.create_bundled_vendor(root)
uv_path = root / "uv"
uv_path.write_bytes(b"uv")
args = SimpleNamespace(
uv=str(uv_path),
runtime_dir=str(runtime_dir),
venv_dir=str(venv_dir),
models_dir=str(root / "models"),
backend="cu128",
torch_variant="cu128",
da3_commit="test-commit",
ben2_commit="test-ben2-commit",
status=str(root / "status.json"),
@@ -1157,7 +1188,7 @@ class BootstrapRuntimeTest(unittest.TestCase):
mocked_verify.assert_not_called()
def test_run_verify_returns_detected_devices(self):
bootstrap = load_bootstrap_runtime()
bootstrap = load_install_env()
class FakeProcess:
def __init__(self, lines):
@@ -1184,7 +1215,7 @@ class BootstrapRuntimeTest(unittest.TestCase):
self.assertTrue(report["mps"])
def test_run_verify_writes_heartbeat_progress(self):
bootstrap = load_bootstrap_runtime()
bootstrap = load_install_env()
class FakeProcess:
def __init__(self, lines):
@@ -1219,14 +1250,14 @@ class BootstrapRuntimeTest(unittest.TestCase):
messages = [call.args[2] for call in mocked_write.call_args_list]
self.assertTrue(
any(
"Checking Backend Environment" in message
"Checking Environment" in message
for message in messages
)
)
self.assertEqual(report, {})
def test_install_uv_extracts_bundled_archive(self):
bootstrap = load_bootstrap_runtime()
bootstrap = load_install_env()
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
uv_path = root / "uv"
@@ -1253,7 +1284,7 @@ class BootstrapRuntimeTest(unittest.TestCase):
self.assertEqual(uv_path.read_bytes(), b"uv-binary")
def test_install_uv_raises_when_bundled_missing(self):
bootstrap = load_bootstrap_runtime()
bootstrap = load_install_env()
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
uv_path = root / "uv"

View File

@@ -18,7 +18,7 @@ class ManifestTest(unittest.TestCase):
self.assertEqual(self.manifest["type"], "add-on")
self.assertNotIn("bl_info", self.manifest)
def test_declares_required_runtime_permissions(self):
def test_declares_required_environment_permissions(self):
self.assertEqual(
set(self.manifest["permissions"]),
{"network", "files"},

View File

@@ -61,7 +61,7 @@ class PackTest(unittest.TestCase):
names,
)
self.assertIn("worker.py", names)
self.assertIn("ai_host.py", names)
self.assertIn("service.py", names)
self.assertNotIn("snapform/__init__.py", names)
self.assertNotIn("importer.py", names)
self.assertIn("vendor/depth-anything-3.tar.gz", names)

View File

@@ -10,10 +10,10 @@ from unittest.mock import patch
PROJECT_ROOT = Path(__file__).resolve().parents[1]
def load_ai_host():
def load_service():
specification = importlib.util.spec_from_file_location(
"snapform_ai_host",
PROJECT_ROOT / "src" / "snapform" / "ai_host.py",
"snapform_service",
PROJECT_ROOT / "src" / "snapform" / "service.py",
)
module = importlib.util.module_from_spec(specification)
specification.loader.exec_module(module)
@@ -33,13 +33,13 @@ class FakeCuda:
return None
class HostTest(unittest.TestCase):
class ServiceTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.host = load_ai_host()
cls.service = load_service()
def create_cache(self):
cache = self.host.ModelCache.__new__(self.host.ModelCache)
cache = self.service.ModelCache.__new__(self.service.ModelCache)
cache.torch = SimpleNamespace(cuda=FakeCuda())
cache.ben2_model = None
cache.ben2_key = None
@@ -111,13 +111,13 @@ class HostTest(unittest.TestCase):
self.assertEqual(loads, ["model"])
self.assertEqual(cache.status()["da3"], "model")
def test_host_tracks_clients_and_rejects_shared_shutdown(self):
def test_service_tracks_clients_and_rejects_shared_shutdown(self):
fake_models = SimpleNamespace(
status=lambda: {"ben2": "", "da3": ""},
close=lambda: None,
)
with patch.object(self.host, "ModelCache", return_value=fake_models):
state = self.host.HostState("token", "instance")
with patch.object(self.service, "ModelCache", return_value=fake_models):
state = self.service.ServiceState("token", "instance")
state.register_client("first", "Blender A")
state.register_client("second", "Blender B")
@@ -131,13 +131,13 @@ class HostTest(unittest.TestCase):
state.reserve("other", "second", "reconstruct", "other.json")
)
def test_cancelling_job_keeps_host_and_model_cache_alive(self):
def test_cancelling_job_keeps_service_and_model_cache_alive(self):
fake_models = SimpleNamespace(
status=lambda: {"ben2": "BEN2_BASE", "da3": ""},
close=lambda: None,
)
with patch.object(self.host, "ModelCache", return_value=fake_models):
state = self.host.HostState("token", "instance")
with patch.object(self.service, "ModelCache", return_value=fake_models):
state = self.service.ServiceState("token", "instance")
state.register_client("client", "Blender")
with tempfile.TemporaryDirectory() as directory:
status_path = Path(directory) / "status.json"
@@ -158,7 +158,7 @@ class HostTest(unittest.TestCase):
"parameters": {"status": str(status_path)},
}
)
status = self.host.json.loads(
status = self.service.json.loads(
status_path.read_text(encoding="utf-8")
)
@@ -171,44 +171,44 @@ class HostTest(unittest.TestCase):
status=lambda: {"ben2": "", "da3": ""},
close=lambda: None,
)
with patch.object(self.host, "ModelCache", return_value=fake_models):
state = self.host.HostState("token", "instance")
with patch.object(self.service, "ModelCache", return_value=fake_models):
state = self.service.ServiceState("token", "instance")
state.register_client("client", "Blender")
state.clients["client"]["last_seen"] -= (
self.host.CLIENT_TIMEOUT_SECONDS + 1.0
self.service.CLIENT_TIMEOUT_SECONDS + 1.0
)
state.expire_clients()
self.assertEqual(state.snapshot()["clients"], 0)
def test_discovery_is_only_removed_by_matching_host(self):
def test_discovery_is_only_removed_by_matching_service(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "host.json"
path = Path(directory) / "service.json"
args = SimpleNamespace(
discovery=str(path),
port=51234,
token="token",
instance_id="owner",
)
with patch.object(self.host.os, "getpid", return_value=42):
self.host.write_discovery(args)
with patch.object(self.service.os, "getpid", return_value=42):
self.service.write_discovery(args)
payload = self.host.json.loads(path.read_text(encoding="utf-8"))
payload = self.service.json.loads(path.read_text(encoding="utf-8"))
self.assertEqual(payload["port"], 51234)
self.assertEqual(payload["pid"], 42)
self.host.remove_discovery(path, "other")
self.service.remove_discovery(path, "other")
self.assertTrue(path.is_file())
self.host.remove_discovery(path, "owner")
self.service.remove_discovery(path, "owner")
self.assertFalse(path.exists())
def test_host_dispatches_frame_extraction_without_models(self):
def test_service_dispatches_frame_extraction_without_models(self):
fake_models = SimpleNamespace(
status=lambda: {"ben2": "", "da3": ""},
close=lambda: None,
)
with patch.object(self.host, "ModelCache", return_value=fake_models):
state = self.host.HostState("token", "instance")
with patch.object(self.service, "ModelCache", return_value=fake_models):
state = self.service.ServiceState("token", "instance")
state.register_client("client", "Blender")
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
@@ -238,7 +238,7 @@ class HostTest(unittest.TestCase):
},
}
)
status = self.host.json.loads(
status = self.service.json.loads(
status_path.read_text(encoding="utf-8")
)
self.assertTrue(

View File

@@ -214,6 +214,13 @@ def main():
]
assert len(surface_group_nodes) == 1
assert surface_group_nodes[0].name == "O DA3 Surface Layer"
surface_inputs = [
item.name
for item in surface_group.interface.items_tree
if item.item_type == "SOCKET"
and item.in_out == "INPUT"
]
assert surface_inputs == ["Color", "Alpha", "Bump Scale"]
surface_outputs = [
item.name
for item in surface_group.interface.items_tree
@@ -232,14 +239,6 @@ def main():
and item.name == "Background"
for item in refine_group.interface.items_tree
)
surface_depth_input = next(
item
for item in surface_group.interface.items_tree
if item.item_type == "SOCKET"
and item.in_out == "INPUT"
and item.name == "Depth"
)
assert surface_depth_input.socket_type == "NodeSocketFloat"
assert not any(
node.bl_idname in forbidden_outer_nodes
for node in surface_material.node_tree.nodes