From 92d2cea1d1328766971c07e74abbf2f5dc8bbc5f Mon Sep 17 00:00:00 2001 From: nan Date: Thu, 13 Aug 2026 00:35:40 +0800 Subject: [PATCH] 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. --- README.md | 10 +- docs/technical.md | 147 +++--- src/snapform/__init__.py | 16 +- src/snapform/blender_manifest.toml | 4 +- src/snapform/{runtime.py => client.py} | 395 +++++++------- src/snapform/image_depth.py | 35 +- src/snapform/image_mesh.py | 4 +- .../{bootstrap_runtime.py => install_env.py} | 104 ++-- src/snapform/models.py | 2 +- src/snapform/operators.py | 132 ++--- src/snapform/panel.py | 49 +- src/snapform/preferences.py | 18 +- src/snapform/properties.py | 2 +- src/snapform/{ai_host.py => service.py} | 35 +- src/snapform/worker.py | 4 +- tests/test_addon.py | 489 +++++++++--------- ...otstrap_runtime.py => test_install_env.py} | 303 ++++++----- tests/test_manifest.py | 2 +- tests/test_pack.py | 2 +- tests/{test_ai_host.py => test_service.py} | 54 +- tools/validate_depth_plane_asset.py | 15 +- 21 files changed, 935 insertions(+), 887 deletions(-) rename src/snapform/{runtime.py => client.py} (67%) rename src/snapform/{bootstrap_runtime.py => install_env.py} (89%) rename src/snapform/{ai_host.py => service.py} (95%) rename tests/{test_bootstrap_runtime.py => test_install_env.py} (85%) rename tests/{test_ai_host.py => test_service.py} (80%) diff --git a/README.md b/README.md index 490c468..c98666b 100644 --- a/README.md +++ b/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,并确认播放场景帧 diff --git a/docs/technical.md b/docs/technical.md index 5f0a23c..a33b08e 100644 --- a/docs/technical.md +++ b/docs/technical.md @@ -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/ ``` diff --git a/src/snapform/__init__.py b/src/snapform/__init__.py index fa7aa87..9602c25 100644 --- a/src/snapform/__init__.py +++ b/src/snapform/__init__.py @@ -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) diff --git a/src/snapform/blender_manifest.toml b/src/snapform/blender_manifest.toml index a89b63f..7cf6f8a 100644 --- a/src/snapform/blender_manifest.toml +++ b/src/snapform/blender_manifest.toml @@ -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 = [ diff --git a/src/snapform/runtime.py b/src/snapform/client.py similarity index 67% rename from src/snapform/runtime.py rename to src/snapform/client.py index 7ed24ed..7995847 100644 --- a/src/snapform/runtime.py +++ b/src/snapform/client.py @@ -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, } diff --git a/src/snapform/image_depth.py b/src/snapform/image_depth.py index 99af4f0..1c5920d 100644 --- a/src/snapform/image_depth.py +++ b/src/snapform/image_depth.py @@ -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", diff --git a/src/snapform/image_mesh.py b/src/snapform/image_mesh.py index cfb85f9..db4314b 100644 --- a/src/snapform/image_mesh.py +++ b/src/snapform/image_mesh.py @@ -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) ) diff --git a/src/snapform/bootstrap_runtime.py b/src/snapform/install_env.py similarity index 89% rename from src/snapform/bootstrap_runtime.py rename to src/snapform/install_env.py index d36dab6..cca7cdd 100644 --- a/src/snapform/bootstrap_runtime.py +++ b/src/snapform/install_env.py @@ -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 diff --git a/src/snapform/models.py b/src/snapform/models.py index 6b8fd67..f5c19a8 100644 --- a/src/snapform/models.py +++ b/src/snapform/models.py @@ -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 diff --git a/src/snapform/operators.py b/src/snapform/operators.py index 34af474..0b7b33b 100644 --- a/src/snapform/operators.py +++ b/src/snapform/operators.py @@ -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)) diff --git a/src/snapform/panel.py b/src/snapform/panel.py index 834f3a5..1340f6d 100644 --- a/src/snapform/panel.py +++ b/src/snapform/panel.py @@ -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( diff --git a/src/snapform/preferences.py b/src/snapform/preferences.py index 50eb1c5..88948cd 100644 --- a/src/snapform/preferences.py +++ b/src/snapform/preferences.py @@ -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", ) diff --git a/src/snapform/properties.py b/src/snapform/properties.py index f5ba62e..19e5028 100644 --- a/src/snapform/properties.py +++ b/src/snapform/properties.py @@ -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): diff --git a/src/snapform/ai_host.py b/src/snapform/service.py similarity index 95% rename from src/snapform/ai_host.py rename to src/snapform/service.py index bcb1df2..c3e625d 100644 --- a/src/snapform/ai_host.py +++ b/src/snapform/service.py @@ -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__": diff --git a/src/snapform/worker.py b/src/snapform/worker.py index 5becf32..c5c04d7 100644 --- a/src/snapform/worker.py +++ b/src/snapform/worker.py @@ -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: diff --git a/tests/test_addon.py b/tests/test_addon.py index 430b134..3003b5f 100644 --- a/tests/test_addon.py +++ b/tests/test_addon.py @@ -160,7 +160,7 @@ class AddonTest(unittest.TestCase): menu = self.addon.SnapFormImageMenu() menu.layout = Layout() - with patch.object(self.addon.runtime, "runtime_ready", return_value=True): + with patch.object(self.addon.client, "env_installed", return_value=True): menu.draw(None) self.assertEqual( @@ -173,7 +173,7 @@ class AddonTest(unittest.TestCase): ], ) - def test_image_context_menu_only_installs_backend_when_missing(self): + def test_image_context_menu_only_installs_env_when_missing(self): calls = [] class Operator: @@ -192,14 +192,14 @@ class AddonTest(unittest.TestCase): menu = self.addon.SnapFormImageMenu() menu.layout = Layout() - with patch.object(self.addon.runtime, "runtime_ready", return_value=False): + with patch.object(self.addon.client, "env_installed", return_value=False): menu.draw(None) self.assertEqual(len(calls), 1) identifier, options, operator = calls[0] self.assertEqual(identifier, "snapform.run_job") - self.assertEqual(options["text"], "Install Backend") - self.assertEqual(operator.job_type, "INSTALL_RUNTIME") + self.assertEqual(options["text"], "Install Environment") + self.assertEqual(operator.job_type, "INSTALL_ENV") def test_image_mesh_reference_plane_matches_image_empty_bounds(self): source_object = SimpleNamespace( @@ -716,7 +716,7 @@ class AddonTest(unittest.TestCase): ) model_items_callback = operator.__annotations__["snapform_model"]["items"] with patch.object( - self.addon.runtime, + self.addon.client, "model_ready", return_value=True, ): @@ -751,7 +751,7 @@ class AddonTest(unittest.TestCase): self.addon.image_depth._last_depth_plane_model = "GIANT_1_1" with patch.object( - self.addon.runtime, + self.addon.client, "model_ready", side_effect=lambda key: key == "GIANT_1_1", ): @@ -844,7 +844,7 @@ class AddonTest(unittest.TestCase): "snapform_model" ]["items"] with patch.object( - self.addon.runtime, + self.addon.client, "model_ready", side_effect=lambda key: key in {"SMALL", "BASE"}, ): @@ -924,7 +924,7 @@ class AddonTest(unittest.TestCase): "items" ] with patch.object( - self.addon.runtime, + self.addon.client, "model_ready", side_effect=lambda key: key == "BASE", ): @@ -969,7 +969,7 @@ class AddonTest(unittest.TestCase): "save_packed_image", return_value=packed_path, ) as save_packed_image, patch.object( - self.addon.runtime, + self.addon.client, "model_ready", return_value=True, ): @@ -1005,7 +1005,7 @@ class AddonTest(unittest.TestCase): with TemporaryDirectory() as directory: output_path = Path(directory) / "input.png" with patch.object( - self.addon.runtime, + self.addon.client, "temporary_input_path", return_value=output_path, ): @@ -1083,7 +1083,7 @@ class AddonTest(unittest.TestCase): temporary_directory = root / "temp-frames" temporary_directory.mkdir() with patch.object( - self.addon.runtime, + self.addon.client, "temporary_input_directory", return_value=temporary_directory, ): @@ -1128,21 +1128,21 @@ class AddonTest(unittest.TestCase): ("NESTED_GIANT_LARGE_1_1", True), ): with self.subTest(model=snapform_model), patch.object( - self.addon.runtime, + self.addon.client, "new_job_paths", return_value=paths, ), patch.object( - self.addon.runtime, - "runtime_ready", + self.addon.client, + "env_installed", return_value=True, ), patch.object( - self.addon.runtime, + self.addon.client, "model_ready", return_value=True, ) as model_ready, patch.object( - self.addon.runtime, + self.addon.client, "reconstruct_request", - return_value=self.addon.runtime.HostJobRequest( + return_value=self.addon.client.JobRequest( {"job_id": "test"} ), ) as reconstruct: @@ -1155,7 +1155,7 @@ class AddonTest(unittest.TestCase): self.assertIsInstance( command, - self.addon.runtime.HostJobRequest, + self.addon.client.JobRequest, ) self.assertEqual(prepared_paths, paths) model_ready.assert_called_once_with(snapform_model) @@ -1200,7 +1200,7 @@ class AddonTest(unittest.TestCase): input_path.touch() operator.snapform_input_path = str(input_path) with patch.object( - self.addon.runtime, + self.addon.client, "new_job_paths", return_value={ "log": Path(directory) / "job.log", @@ -1208,19 +1208,19 @@ class AddonTest(unittest.TestCase): "result": Path(directory) / "result", }, ), patch.object( - self.addon.runtime, - "runtime_ready", + self.addon.client, + "env_installed", return_value=True, ), patch.object( - self.addon.runtime, + self.addon.client, "model_ready", side_effect=lambda key: key == "BASE", ): with self.assertRaisesRegex(RuntimeError, "BEN2 Base"): operator._prepare_command(None, settings) - def test_image_depth_background_option_reaches_host_request(self): - runtime = importlib.import_module("snapform.runtime") + def test_image_depth_background_option_reaches_service_request(self): + client = importlib.import_module("snapform.client") settings = SimpleNamespace( input_path="input.png", model="BASE", @@ -1237,15 +1237,15 @@ class AddonTest(unittest.TestCase): ref_view_strategy="saddle_balanced", ) with patch.object( - runtime, - "runtime_python", + client, + "venv_python", return_value=Path("python"), ), patch.object( - runtime, + client, "model_directory", side_effect=lambda key: Path(key.lower()), ): - request = runtime.reconstruct_request( + request = client.reconstruct_request( settings, "mini_npz", Path("status.json"), @@ -1262,21 +1262,21 @@ class AddonTest(unittest.TestCase): request.payload["parameters"], ) - def test_host_busy_snapshot_is_released_for_completed_local_job(self): - runtime = self.addon.runtime - previous_snapshot = runtime._host_snapshot - runtime._host_snapshot = { + def test_service_busy_snapshot_is_released_for_completed_local_job(self): + client = self.addon.client + previous_snapshot = client._service_snapshot + client._service_snapshot = { "state": "BUSY", "busy": True, "active_job": {"job_id": "depth-job"}, } try: - runtime.mark_host_job_complete("depth-job") + client.mark_service_job_complete("depth-job") - self.assertEqual(runtime.host_status()["state"], "READY") - self.assertFalse(runtime.host_busy()) + self.assertEqual(client.service_status()["state"], "READY") + self.assertFalse(client.service_busy()) finally: - runtime._host_snapshot = previous_snapshot + client._service_snapshot = previous_snapshot def test_job_submission_starts_without_blocking_execute(self): operator = self.addon.RunJob() @@ -1310,26 +1310,26 @@ class AddonTest(unittest.TestCase): "status": Path("job.json"), "result": Path("result"), } - request = self.addon.runtime.HostJobRequest({"job_id": "job"}) + request = self.addon.client.JobRequest({"job_id": "job"}) try: with patch.object( operator, "_prepare_command", return_value=(request, paths), ), patch.object( - self.addon.runtime, - "submit_host_job", + self.addon.client, + "submit_service_job", side_effect=submit, ), patch.object( - self.addon.runtime, - "host_log_path", + self.addon.client, + "service_log_path", return_value=Path("host.log"), ), patch.object( - self.addon.runtime, + self.addon.client, "storage_root", return_value=Path("."), ), patch.object( - self.addon.runtime, + self.addon.client, "creation_flags", return_value=0, ), patch.object(operator, "_redraw_ui"): @@ -1354,45 +1354,45 @@ class AddonTest(unittest.TestCase): ) def test_default_storage_is_shared_in_user_home(self): - runtime = importlib.import_module("snapform.runtime") + client = importlib.import_module("snapform.client") - self.assertEqual(runtime.DEFAULT_STORAGE_ROOT.name, ".snapform") + self.assertEqual(client.DEFAULT_STORAGE_ROOT.name, ".snapform") self.assertEqual( self.addon.SnapFormPreferences.__annotations__["storage_root"][ "default" ], - str(runtime.DEFAULT_STORAGE_ROOT), + str(client.DEFAULT_STORAGE_ROOT), ) - def test_ai_host_uses_loopback_address(self): - runtime = importlib.import_module("snapform.runtime") + def test_ai_service_uses_loopback_address(self): + client = importlib.import_module("snapform.client") - self.assertEqual(runtime.HOST_ADDRESS, "127.0.0.1") + self.assertEqual(client.SERVICE_ADDRESS, "127.0.0.1") - def test_closing_blender_terminates_host_it_started(self): - runtime = importlib.import_module("snapform.runtime") - previous_connection = runtime._host_connection - previous_registered = runtime._registered_instance_id - runtime._host_connection = SimpleNamespace(port=12345) - runtime._registered_instance_id = "instance" + def test_closing_blender_terminates_service_it_started(self): + client = importlib.import_module("snapform.client") + previous_connection = client._service_connection + previous_registered = client._registered_instance_id + client._service_connection = SimpleNamespace(port=12345) + client._registered_instance_id = "instance" try: with patch.object( - runtime, - "_host_http_request", + client, + "_service_http_request", ) as request, patch.object( - runtime, - "_close_launched_host", - ) as close_host: - runtime.detach_host_client() + client, + "_close_launched_service", + ) as close_service: + client.detach_service_client() request.assert_called_once() self.assertEqual(request.call_args.args[1], "DELETE") - close_host.assert_called_once_with(terminate=True) + close_service.assert_called_once_with(terminate=True) finally: - runtime._host_connection = previous_connection - runtime._registered_instance_id = previous_registered - self.assertIsNone(runtime._host_connection) - self.assertIsNone(runtime._registered_instance_id) + client._service_connection = previous_connection + client._registered_instance_id = previous_registered + self.assertIsNone(client._service_connection) + self.assertIsNone(client._registered_instance_id) def test_process_res_method_defaults_to_upper_bound_resize(self): process_res = self.addon.SnapFormSettings.__annotations__[ @@ -1408,7 +1408,7 @@ class AddonTest(unittest.TestCase): self.assertEqual(process_res_method["default"], "upper_bound_resize") def test_reconstruct_request_preserves_da3_parameter_names(self): - runtime = importlib.import_module("snapform.runtime") + client = importlib.import_module("snapform.client") settings = SimpleNamespace( input_path="input.png", model="GIANT_1_1", @@ -1425,15 +1425,15 @@ class AddonTest(unittest.TestCase): ref_view_strategy="saddle_balanced", ) with patch.object( - runtime, - "runtime_python", + client, + "venv_python", return_value=Path("python"), ), patch.object( - runtime, + client, "model_directory", return_value=Path("model"), ): - request = runtime.reconstruct_request( + request = client.reconstruct_request( settings, "gs_ply", Path("status.json"), @@ -1457,18 +1457,18 @@ class AddonTest(unittest.TestCase): self.assertIn(parameter, parameters) def test_remove_background_request_passes_video_frames(self): - runtime = importlib.import_module("snapform.runtime") + client = importlib.import_module("snapform.client") settings = SimpleNamespace(device="AUTO", video_frames=24) with patch.object( - runtime, - "runtime_python", + client, + "venv_python", return_value=Path("python"), ), patch.object( - runtime, + client, "model_directory", return_value=Path("ben2_base"), ): - request = runtime.remove_background_request( + request = client.remove_background_request( settings, Path("clip.mp4"), Path("status.json"), @@ -1483,10 +1483,10 @@ class AddonTest(unittest.TestCase): self.assertNotIn("background_depth_distance", parameters) def test_extract_frames_request_passes_frame_range(self): - runtime = importlib.import_module("snapform.runtime") + client = importlib.import_module("snapform.client") settings = SimpleNamespace(device="AUTO", video_frames=24) - request = runtime.extract_frames_request( + request = client.extract_frames_request( settings, Path("clip.mp4"), Path("status.json"), @@ -1502,38 +1502,43 @@ class AddonTest(unittest.TestCase): self.assertEqual(parameters["start_frame"], 70) def test_new_job_paths_wraps_each_job_in_a_folder(self): - runtime = importlib.import_module("snapform.runtime") + client = importlib.import_module("snapform.client") with TemporaryDirectory() as directory: jobs_root = Path(directory) with patch.object( - runtime, + client, "jobs_directory", return_value=jobs_root, ): - paths = runtime.new_job_paths("RECONSTRUCT") + paths = client.new_job_paths("RECONSTRUCT") self.assertTrue(paths["result"].is_dir()) self.assertEqual(paths["result"].parent, jobs_root) self.assertEqual(paths["log"].parent, paths["result"]) self.assertEqual(paths["status"].parent, paths["result"]) - self.assertEqual(paths["status"].stem, paths["result"].name) + self.assertEqual(paths["log"].name, "job.log") + self.assertEqual(paths["status"].name, "status.json") + self.assertEqual( + client.job_id_from_status(paths["status"]), + paths["result"].name, + ) def test_new_job_paths_uses_unique_action_object_names(self): - runtime = importlib.import_module("snapform.runtime") + client = importlib.import_module("snapform.client") with TemporaryDirectory() as directory: jobs_root = Path(directory) with patch.object( - runtime, + client, "jobs_directory", return_value=jobs_root, ): - depth_paths = runtime.new_job_paths( + depth_paths = client.new_job_paths( self.addon.operators.IMAGE_DEPTH_JOB ) - background_paths = runtime.new_job_paths( + background_paths = client.new_job_paths( self.addon.operators.IMAGE_BACKGROUND_JOB ) - frames_paths = runtime.new_job_paths( + frames_paths = client.new_job_paths( self.addon.operators.ORIGINAL_FRAMES_JOB ) @@ -1553,7 +1558,7 @@ class AddonTest(unittest.TestCase): ) def test_generated_inputs_are_moved_into_job_root(self): - runtime = importlib.import_module("snapform.runtime") + client = importlib.import_module("snapform.client") with TemporaryDirectory() as directory: root = Path(directory) staging = root / "staging" @@ -1563,7 +1568,7 @@ class AddonTest(unittest.TestCase): job_directory = root / "job" job_directory.mkdir() - result = runtime.move_input_into_job(staging, job_directory) + result = client.move_input_into_job(staging, job_directory) self.assertEqual(result, job_directory) self.assertFalse(staging.exists()) @@ -1655,21 +1660,21 @@ class AddonTest(unittest.TestCase): "result": Path(directory) / "result", } with patch.object( - self.addon.runtime, + self.addon.client, "new_job_paths", return_value=paths, ), patch.object( - self.addon.runtime, - "runtime_ready", + self.addon.client, + "env_installed", return_value=True, ), patch.object( - self.addon.runtime, + self.addon.client, "model_ready", return_value=True, ), patch.object( - self.addon.runtime, + self.addon.client, "reconstruct_request", - return_value=self.addon.runtime.HostJobRequest( + return_value=self.addon.client.JobRequest( {"job_id": "test"} ), ) as reconstruct: @@ -1680,7 +1685,7 @@ class AddonTest(unittest.TestCase): self.assertEqual(reconstruct.call_args.kwargs["start_frame"], 71) def test_reconstruct_request_accepts_image_depth_overrides(self): - runtime = importlib.import_module("snapform.runtime") + client = importlib.import_module("snapform.client") settings = SimpleNamespace( input_path="input.png", model="BASE", @@ -1697,15 +1702,15 @@ class AddonTest(unittest.TestCase): ref_view_strategy="saddle_balanced", ) with patch.object( - runtime, - "runtime_python", + client, + "venv_python", return_value=Path("python"), ), patch.object( - runtime, + client, "model_directory", return_value=Path("large-model"), ) as snapform_model_directory: - request = runtime.reconstruct_request( + request = client.reconstruct_request( settings, "mini_npz", Path("status.json"), @@ -1726,7 +1731,7 @@ class AddonTest(unittest.TestCase): self.assertEqual(parameters["use_ray_pose"], 0) def test_reconstruct_request_enables_ben2_for_image_mesh(self): - runtime = importlib.import_module("snapform.runtime") + client = importlib.import_module("snapform.client") settings = SimpleNamespace( input_path="input.png", model="BASE", @@ -1743,15 +1748,15 @@ class AddonTest(unittest.TestCase): ref_view_strategy="saddle_balanced", ) with patch.object( - runtime, - "runtime_python", + client, + "venv_python", return_value=Path("python"), ), patch.object( - runtime, + client, "model_directory", side_effect=lambda key: Path(key.lower()), ): - request = runtime.reconstruct_request( + request = client.reconstruct_request( settings, "mesh_data", Path("status.json"), @@ -1823,21 +1828,21 @@ class AddonTest(unittest.TestCase): "result": Path(directory) / "result", } with patch.object( - self.addon.runtime, + self.addon.client, "new_job_paths", return_value=paths, ), patch.object( - self.addon.runtime, - "runtime_ready", + self.addon.client, + "env_installed", return_value=True, ), patch.object( - self.addon.runtime, + self.addon.client, "model_ready", return_value=True, ), patch.object( - self.addon.runtime, + self.addon.client, "reconstruct_request", - return_value=self.addon.runtime.HostJobRequest( + return_value=self.addon.client.JobRequest( {"job_id": "test"} ), ) as reconstruct: @@ -1868,13 +1873,13 @@ class AddonTest(unittest.TestCase): ) def test_device_enums_list_only_supported_devices(self): - runtime = importlib.import_module("snapform.runtime") + client = importlib.import_module("snapform.client") with patch.object( - runtime, + client, "supported_devices", return_value=["cuda", "cpu"], ): - items = runtime.device_enum_items(None, None) + items = client.device_enum_items(None, None) self.assertEqual([item[0] for item in items], ["CUDA", "CPU"]) self.assertEqual(items[0][2], "NVIDIA CUDA") @@ -1896,7 +1901,7 @@ class AddonTest(unittest.TestCase): self.assertIn("device", preferences.__annotations__) items_callback = preferences.__annotations__["device"]["items"] with patch.object( - self.addon.runtime, + self.addon.client, "supported_devices", return_value=["cpu"], ): @@ -1912,21 +1917,21 @@ class AddonTest(unittest.TestCase): ) ) with patch.object( - self.addon.runtime, - "host_busy", + self.addon.client, + "service_busy", return_value=False, ), patch.object( - self.addon.runtime, + self.addon.client, "model_ready", return_value=False, ): self.assertFalse(self.addon.RemoveImageBackground.poll(context)) with patch.object( - self.addon.runtime, - "host_busy", + self.addon.client, + "service_busy", return_value=False, ), patch.object( - self.addon.runtime, + self.addon.client, "model_ready", return_value=True, ): @@ -1941,11 +1946,11 @@ class AddonTest(unittest.TestCase): ) ) with patch.object( - self.addon.runtime, - "host_busy", + self.addon.client, + "service_busy", return_value=False, ), patch.object( - self.addon.runtime, + self.addon.client, "model_ready", side_effect=lambda key: key in {"BASE", "BEN2_BASE"}, ): @@ -1953,11 +1958,11 @@ class AddonTest(unittest.TestCase): self.addon.ConvertImageToDepthPlane.poll(context) ) with patch.object( - self.addon.runtime, - "host_busy", + self.addon.client, + "service_busy", return_value=False, ), patch.object( - self.addon.runtime, + self.addon.client, "model_ready", return_value=False, ): @@ -1982,77 +1987,81 @@ class AddonTest(unittest.TestCase): ["DA3 Output Test", "BEN2 Removal Test"], ) - def test_host_panel_exposes_shared_host_state_and_controls(self): - self.assertIn(self.addon.HostPanel, self.addon.CLASSES) - self.assertIn(self.addon.ControlHost, self.addon.CLASSES) - self.assertEqual(self.addon.HostPanel.bl_label, "AI Backend") + def test_service_panel_exposes_shared_service_state_and_controls(self): + self.assertIn(self.addon.AiServicePanel, self.addon.CLASSES) + self.assertIn(self.addon.ControlService, self.addon.CLASSES) + self.assertEqual(self.addon.AiServicePanel.bl_label, "AI Service") - def test_ai_operation_restarts_a_manually_stopped_host(self): - runtime = self.addon.runtime - connection = runtime.HostConnection(51234, "token", 42, "host") + def test_models_is_an_independent_closed_panel(self): + self.assertIn(self.addon.ModelsPanel, self.addon.CLASSES) + self.assertNotIn("bl_parent_id", self.addon.ModelsPanel.__dict__) + self.assertIn("DEFAULT_CLOSED", self.addon.ModelsPanel.bl_options) + + def test_ai_operation_restarts_a_manually_stopped_service(self): + client = self.addon.client + connection = client.Connection(51234, "token", 42, "host") health = { - "job_protocol_version": runtime.JOB_PROTOCOL_VERSION, "instance_id": "host", "busy": False, "clients": 1, "cached_models": {}, } - previous_auto_start = runtime._host_auto_start - previous_connection = runtime._host_connection - previous_snapshot = runtime._host_snapshot - runtime._host_auto_start = False - runtime._host_connection = None - runtime._host_snapshot = {"state": "STOPPED", "message": "Stopped"} + previous_auto_start = client._service_auto_start + previous_connection = client._service_connection + previous_snapshot = client._service_snapshot + client._service_auto_start = False + client._service_connection = None + client._service_snapshot = {"state": "STOPPED", "message": "Stopped"} try: with patch.object( - runtime, - "_discover_live_host", + client, + "_discover_live_service", return_value=(connection, health), ), patch.object( - runtime, - "_register_host_client", + client, + "_register_service_client", ), patch.object( - runtime, - "_check_host", + client, + "_check_service", return_value=health, - ), patch.object(runtime, "_redraw_host_ui"): - result = runtime.ensure_host(timeout=0.1) + ), patch.object(client, "_redraw_service_ui"): + result = client.ensure_service(timeout=0.1) self.assertIs(result, connection) - self.assertTrue(runtime._host_auto_start) - self.assertEqual(runtime.host_status()["state"], "READY") + self.assertTrue(client._service_auto_start) + self.assertEqual(client.service_status()["state"], "READY") finally: - runtime._host_auto_start = previous_auto_start - runtime._host_connection = previous_connection - runtime._host_snapshot = previous_snapshot + client._service_auto_start = previous_auto_start + client._service_connection = previous_connection + client._service_snapshot = previous_snapshot - def test_blender_startup_does_not_start_ai_host(self): - runtime = self.addon.runtime - previous_auto_start = runtime._host_auto_start - previous_snapshot = runtime._host_snapshot - runtime._host_auto_start = False - runtime._host_snapshot = {"state": "STOPPED", "message": "Stopped"} + def test_blender_startup_does_not_start_ai_service(self): + client = self.addon.client + previous_auto_start = client._service_auto_start + previous_snapshot = client._service_snapshot + client._service_auto_start = False + client._service_snapshot = {"state": "STOPPED", "message": "Stopped"} try: with patch.object( - runtime, - "runtime_ready", + client, + "env_installed", return_value=True, ), patch.object( - runtime, - "_discover_live_host", + client, + "_discover_live_service", return_value=(None, None), ), patch.object( - runtime, - "_close_finished_launched_host", - ), patch.object(runtime, "start_host") as start_host: - interval = runtime._service_host_timer() + client, + "_close_finished_launched_service", + ), patch.object(client, "start_service") as start_service: + interval = client._service_timer() - start_host.assert_not_called() + start_service.assert_not_called() self.assertEqual(interval, 2.0) - self.assertEqual(runtime.host_status()["state"], "STOPPED") + self.assertEqual(client.service_status()["state"], "STOPPED") finally: - runtime._host_auto_start = previous_auto_start - runtime._host_snapshot = previous_snapshot + client._service_auto_start = previous_auto_start + client._service_snapshot = previous_snapshot def test_ben2_debug_job_uses_its_image_and_model(self): settings = SimpleNamespace( @@ -2073,21 +2082,21 @@ class AddonTest(unittest.TestCase): "result": root / "result", } with patch.object( - self.addon.runtime, + self.addon.client, "new_job_paths", return_value=paths, ), patch.object( - self.addon.runtime, - "runtime_ready", + self.addon.client, + "env_installed", return_value=True, ), patch.object( - self.addon.runtime, + self.addon.client, "model_ready", return_value=True, ) as model_ready, patch.object( - self.addon.runtime, + self.addon.client, "remove_background_request", - return_value=self.addon.runtime.HostJobRequest( + return_value=self.addon.client.JobRequest( {"job_id": "test"} ), ) as remove_background: @@ -2096,7 +2105,7 @@ class AddonTest(unittest.TestCase): settings, ) - self.assertIsInstance(command, self.addon.runtime.HostJobRequest) + self.assertIsInstance(command, self.addon.client.JobRequest) self.assertEqual(prepared_paths, paths) model_ready.assert_called_once_with("BEN2_BASE") self.assertEqual(remove_background.call_args.args[1], input_path) @@ -2121,21 +2130,21 @@ class AddonTest(unittest.TestCase): "result": root / "result", } with patch.object( - self.addon.runtime, + self.addon.client, "new_job_paths", return_value=paths, ), patch.object( - self.addon.runtime, - "runtime_ready", + self.addon.client, + "env_installed", return_value=True, ), patch.object( - self.addon.runtime, + self.addon.client, "model_ready", return_value=True, ), patch.object( - self.addon.runtime, + self.addon.client, "remove_background_request", - return_value=self.addon.runtime.HostJobRequest( + return_value=self.addon.client.JobRequest( {"job_id": "test"} ), ) as remove_background: @@ -2162,25 +2171,25 @@ class AddonTest(unittest.TestCase): "result": root / "result", } with patch.object( - self.addon.runtime, + self.addon.client, "new_job_paths", return_value=paths, ), patch.object( - self.addon.runtime, - "runtime_ready", + self.addon.client, + "env_installed", return_value=True, ), patch.object( - self.addon.runtime, + self.addon.client, "model_ready", return_value=True, ), patch.object( - self.addon.runtime, + self.addon.client, "production_device", return_value="MPS", ), patch.object( - self.addon.runtime, + self.addon.client, "remove_background_request", - return_value=self.addon.runtime.HostJobRequest( + return_value=self.addon.client.JobRequest( {"job_id": "test"} ), ) as remove_background: @@ -2207,21 +2216,21 @@ class AddonTest(unittest.TestCase): "result": root / "result", } with patch.object( - self.addon.runtime, + self.addon.client, "new_job_paths", return_value=paths, ), patch.object( - self.addon.runtime, - "runtime_ready", + self.addon.client, + "env_installed", return_value=True, ), patch.object( - self.addon.runtime, + self.addon.client, "model_ready", return_value=True, ), patch.object( - self.addon.runtime, + self.addon.client, "remove_background_request", - return_value=self.addon.runtime.HostJobRequest( + return_value=self.addon.client.JobRequest( {"job_id": "test"} ), ) as remove_background: @@ -2230,7 +2239,7 @@ class AddonTest(unittest.TestCase): settings, ) - self.assertIsInstance(command, self.addon.runtime.HostJobRequest) + self.assertIsInstance(command, self.addon.client.JobRequest) self.assertEqual(prepared_paths, paths) self.assertEqual(remove_background.call_args.args[1], input_path) @@ -2250,21 +2259,21 @@ class AddonTest(unittest.TestCase): "result": root / "result", } with patch.object( - self.addon.runtime, + self.addon.client, "new_job_paths", return_value=paths, ), patch.object( - self.addon.runtime, - "runtime_ready", + self.addon.client, + "env_installed", return_value=True, ), patch.object( - self.addon.runtime, + self.addon.client, "model_ready", return_value=True, ), patch.object( - self.addon.runtime, + self.addon.client, "remove_background_request", - return_value=self.addon.runtime.HostJobRequest( + return_value=self.addon.client.JobRequest( {"job_id": "test"} ), ) as remove_background: @@ -2273,7 +2282,7 @@ class AddonTest(unittest.TestCase): settings, ) - self.assertIsInstance(command, self.addon.runtime.HostJobRequest) + self.assertIsInstance(command, self.addon.client.JobRequest) self.assertEqual(remove_background.call_args.args[1], input_path) def test_original_frames_job_uses_frame_extraction_request(self): @@ -2292,17 +2301,17 @@ class AddonTest(unittest.TestCase): "result": root / "result", } with patch.object( - self.addon.runtime, + self.addon.client, "new_job_paths", return_value=paths, ), patch.object( - self.addon.runtime, - "runtime_ready", + self.addon.client, + "env_installed", return_value=True, ), patch.object( - self.addon.runtime, + self.addon.client, "extract_frames_request", - return_value=self.addon.runtime.HostJobRequest( + return_value=self.addon.client.JobRequest( {"job_id": "test"} ), ) as extract_frames: @@ -2311,7 +2320,7 @@ class AddonTest(unittest.TestCase): settings, ) - self.assertIsInstance(command, self.addon.runtime.HostJobRequest) + self.assertIsInstance(command, self.addon.client.JobRequest) self.assertEqual(prepared_paths, paths) self.assertEqual(extract_frames.call_args.args[1], input_path) self.assertEqual( @@ -2482,17 +2491,17 @@ class AddonTest(unittest.TestCase): ) def test_ben2_download_command_filters_large_alternative_weights(self): - runtime = importlib.import_module("snapform.runtime") + client = importlib.import_module("snapform.client") with patch.object( - runtime, - "runtime_python", + client, + "venv_python", return_value=Path("python"), ), patch.object( - runtime, + client, "model_directory", return_value=Path("model"), ): - command = runtime.download_model_command( + command = client.download_model_command( "BEN2_BASE", Path("status.json"), ) @@ -2506,63 +2515,63 @@ class AddonTest(unittest.TestCase): label_index = command.index("--label") self.assertEqual(command[label_index + 1], "BEN2 Base") - def test_install_runtime_command_has_no_model_label_reference(self): - runtime = importlib.import_module("snapform.runtime") + def test_install_env_command_has_no_model_label_reference(self): + client = importlib.import_module("snapform.client") with patch.object( - runtime, + client, "uv_executable", return_value="C:/uv/uv.exe", ), patch.object( - runtime, + client, "machine_devices", return_value=["cuda", "cpu"], ), patch.object( - runtime, - "runtime_directory", - return_value=Path("runtime"), + client, + "venv_directory", + return_value=Path(".venv"), ), patch.object( - runtime, + client, "models_directory", return_value=Path("models"), ): - command = runtime.install_runtime_command(Path("status.json")) + command = client.install_env_command(Path("status.json")) self.assertNotIn("--label", command) self.assertEqual(command[0], sys.executable) self.assertIn("--uv", command) self.assertIn("C:/uv/uv.exe", command) self.assertEqual( - command[command.index("--backend") + 1], + command[command.index("--torch-variant") + 1], "cu128", ) self.assertTrue( - any("bootstrap_runtime.py" in str(value) for value in command) + any("install_env.py" in str(value) for value in command) ) - def test_install_runtime_command_auto_selects_backend(self): - runtime = importlib.import_module("snapform.runtime") + def test_install_env_command_auto_selects_torch_variant(self): + client = importlib.import_module("snapform.client") with patch.object( - runtime, + client, "uv_executable", return_value="C:/uv/uv.exe", ), patch.object( - runtime, + client, "machine_devices", return_value=["cpu"], ), patch.object( - runtime, - "runtime_directory", - return_value=Path("runtime"), + client, + "venv_directory", + return_value=Path(".venv"), ), patch.object( - runtime, + client, "models_directory", return_value=Path("models"), ): - command = runtime.install_runtime_command(Path("status.json")) + command = client.install_env_command(Path("status.json")) self.assertNotIn("--pypi-index", command) self.assertEqual( - command[command.index("--backend") + 1], + command[command.index("--torch-variant") + 1], "cpu", ) self.assertIn("C:/uv/uv.exe", command) @@ -2589,7 +2598,7 @@ class AddonTest(unittest.TestCase): encoding="utf-8", ) job = SimpleNamespace( - kind="INSTALL_RUNTIME", + kind="INSTALL_ENV", cancelled=False, log_file=None, status_path=status_path, diff --git a/tests/test_bootstrap_runtime.py b/tests/test_install_env.py similarity index 85% rename from tests/test_bootstrap_runtime.py rename to tests/test_install_env.py index 1a54d2d..9dd7463 100644 --- a/tests/test_bootstrap_runtime.py +++ b/tests/test_install_env.py @@ -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" diff --git a/tests/test_manifest.py b/tests/test_manifest.py index be06009..9190cd5 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -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"}, diff --git a/tests/test_pack.py b/tests/test_pack.py index e2f0a72..14f08ce 100644 --- a/tests/test_pack.py +++ b/tests/test_pack.py @@ -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) diff --git a/tests/test_ai_host.py b/tests/test_service.py similarity index 80% rename from tests/test_ai_host.py rename to tests/test_service.py index 3156bea..a477608 100644 --- a/tests/test_ai_host.py +++ b/tests/test_service.py @@ -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( diff --git a/tools/validate_depth_plane_asset.py b/tools/validate_depth_plane_asset.py index 0103b2e..dc64a1a 100644 --- a/tools/validate_depth_plane_asset.py +++ b/tools/validate_depth_plane_asset.py @@ -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