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:
10
README.md
10
README.md
@@ -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,并确认播放场景帧
|
||||
|
||||
@@ -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/
|
||||
```
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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__":
|
||||
@@ -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
@@ -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"
|
||||
@@ -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"},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user