diff --git a/README.md b/README.md index 8a73fc5..490c468 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 Runtime` +5. 打开 3D 视图右侧的 `SnapForm` 面板,点击 `Install Backend` DA3、BEN2 源码包和 uv 已内置在扩展里,安装时直接从本地解包,不需要访问 GitHub。首次使用仍会自动 下载运行环境依赖(PyTorch 等),随后自动下载 `DA3 Small` 和 `BEN2 Base`。其他 AI 模型可在 -`Host > Models` 中按需下载,请保持网络畅通,首次安装需要一些时间。 +`AI Backend > Models` 中按需下载,请保持网络畅通,首次安装需要一些时间。 ## 快速上手 @@ -55,7 +55,7 @@ confidence 与边拉伸结果会保存为 Surface attributes,再由一个可 以及分割线下方的 `Keep Original`。 界面会显示计算所得的实际 Process Res。剔除和重拓扑参数直接在 Geometry Nodes 修改器中调整;`Unshaded` 可使用与 Depth Plane 相同的 Emission 材质。普通材质的 -Bump Scale 会在材质组内乘以 `0.001`,并按透视表面的深度空间缩放。Surface attributes 统一使用 `o_` 前缀;Host 准备过程显示 +Bump Scale 会在材质组内乘以 `0.001`,并按透视表面的深度空间缩放。Surface attributes 统一使用 `o_` 前缀;Backend Service 准备过程显示 `Preparing surface data n/6`,可看到当前阶段与总步骤。 ### 视频与序列帧 @@ -72,8 +72,8 @@ SnapForm 支持视频(mp4、mkv、mov 等)和序列帧,会逐帧处理并 ## 常见问题 -- 首次使用需要下载模型,如果下载慢或失败,稍后在 `Host > Models` 里重试即可 -- 安装 Runtime 时提示下载超时:新版会自动探测并切换到镜像、失败时自动重试一次;如果仍然 +- 首次使用需要下载模型,如果下载慢或失败,稍后在 `AI Backend > Models` 里重试即可 +- 安装 Backend 时提示下载超时:新版会自动探测并切换到镜像、失败时自动重试一次;如果仍然 超时,在扩展偏好设置的 `PyPI Index` 中填入镜像地址(如清华)后重新安装即可 - 找不到右键入口时,请确认选中了 Empty,且显示方式为 Image - 动画面片不动:检查 Empty 的 `Image Frames` 是否大于 1,并确认播放场景帧 diff --git a/docs/technical.md b/docs/technical.md index 1092f15..5f0a23c 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 能力,在独立 Runtime 中运行 DA3,可将官方导出结果保存在独立 job 目录, +作为首个 AI 能力,在独立 Backend Environment 中运行 DA3,可将官方导出结果保存在独立 job 目录, 也可把 Image Empty 转换成带几何与材质置换的立体面片。Debug 面板还可运行 [BEN2](https://github.com/PramaLLC/BEN2) 背景剔除功能测试。 ## 当前功能 - 使用 Blender Extension manifest,可通过 `Install from Disk` 安装 -- 在独立 Python Runtime 的常驻 HTTP AI Host 中运行 Torch、DA3 与 BEN2,避免污染 Blender Python 进程 -- Blender 使用内置 `urllib.request` 访问仅监听 `127.0.0.1` 的 Host;请求带随机 Token -- 常驻 Host 复用 Torch、CUDA、BEN2 与最近使用的 DA3 模型,连续执行时不再重复加载 -- 多个 Blender 实例自动连接同一个共享 Host;`Host` 面板显示状态、端口、客户端与模型缓存,并提供安装、启动、清理、重启和停止控制 -- Runtime、模型和任务结果默认保存在 `~/.snapform`,可由多个 Blender 版本共享 +- 在独立 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 版本共享 - 支持 DA3 Small、Base、Large 1.1、Giant 1.1 和 Nested Giant Large 1.1 - 支持 BEN2 Base 单图背景剔除测试,输出透明 PNG 与推理耗时 - 支持单张 JPG、JPEG、PNG、WEBP 图片或常见视频文件输入 - 视频按顺序截取前若干帧,可设置参与重建的最大帧数 -- `Host > Models` 默认折叠,展开后可逐项查看和下载所有可用模型 -- Runtime 已就绪时不额外占用面板空间;模型列表每个模型只占一行 +- `AI Backend > Models` 默认折叠,展开后可逐项查看和下载所有可用模型 +- Backend Environment 已就绪时不额外占用面板空间;模型列表每个模型只占一行 - `Debug` 默认折叠,内部的 `DA3 Output Test` 与 `BEN2 Removal Test` 也分别默认折叠 - 使用一个输出类型下拉菜单和一个 `Generate Output` 按钮 - 只显示当前格式的专属参数;GLB 与 PLY 设置不会同时出现 @@ -42,7 +42,7 @@ - 转换对话框选择推理模式、处理分辨率与背景移除;细分和置换方式在 Geometry Nodes 修改器中调整 - Debug 的 DA3 输出或 BEN2 背景剔除完成后只打开对应 job 文件夹,不修改当前场景 - 完整保留每次任务生成的所有官方文件 -- 安装和模型下载使用后台子进程;推理提交给常驻 AI Host,状态栏实时显示阶段、百分比进度条与取消按钮 +- 安装和模型下载使用后台子进程;推理提交给常驻 Backend Service,状态栏实时显示阶段、百分比进度条与取消按钮 - 后台任务运行时,Install、Download 和 Export 按钮保持原位置并变灰,任务完成后恢复 ## 使用 @@ -55,31 +55,31 @@ 3. 在 Extension Preferences 中确认 Storage Root 与 Device。Device 是正式功能 (Image Empty 右键菜单)使用的推理设备,默认取本机最快的 CUDA/MPS,否则 CPU,且只列出 本机支持的设备。 -4. 在 `Host` 点击 `Install Runtime`。首次安装会下载 Python、PyTorch、FastAPI、Uvicorn; +4. 在 `AI Backend` 点击 `Install Backend`。首次安装会下载 Python、PyTorch、FastAPI、Uvicorn; DA3、BEN2 源码包与 uv 已内置在扩展中,安装时从 `~/.snapform/vendor/` 解包使用, - 并在 Runtime 就绪后自动下载 `DA3 Small` 和 `BEN2 Base` 推荐模型。Runtime 安装 + 并在 Backend Environment 就绪后自动下载 `DA3 Small` 和 `BEN2 Base` 推荐模型。Backend Environment 安装 固定显示 `Environment`、`PyTorch`、`AI components` 三个用户阶段;xFormers(macOS 跳过)、 FastAPI 等内部安装步骤合并在 `AI components` 中。耗时最长的 PyTorch 与 AI 组件占主要进度 - 区间。安装完成后直接写入 Runtime 就绪信息,不再额外导入全部组件进行验证。推荐模型属于安装 + 区间。安装完成后直接写入 Environment Manifest,不再额外导入全部组件进行验证。推荐模型属于安装 后的自动步骤,不计入阶段编号;`DA3 Base` 等其他模型可稍后在 `Models` 面板按需下载。 状态文字使用 `阶段/总数 阶段名 | 当前操作 | 预计剩余时间`,依赖安装会结合预设耗时与实际 进度修正预期;模型下载根据字节数和速度计算剩余时间,例如 `Downloading DA3 Small | 14.2 MB/s | About 2 min left`。重试或切换镜像时进度不会倒退。 - Runtime 就绪后即使模型下载中断也不影响 Host 使用,可稍后在 `Models` 面板单独补下。 + Backend Environment 就绪后即使模型下载中断也不影响 Backend Service 使用,可稍后在 `Models` 面板单独补下。 安装不做启动网络预检:pip 安装失败会自动依次重试清华、阿里镜像,模型下载由 worker 自行探测回退到 HF 镜像;需要手动指定镜像时可通过 `PIP_INDEX_URL` 环境变量覆盖。 5. 在 3D View 按 `N`,打开 `SnapForm` 页签。 -6. 如需其他模型,展开 `Host > Models`,找到模型并点击该行的 `Download`。 +6. 如需其他模型,展开 `AI Backend > Models`,找到模型并点击该行的 `Download`。 7. 展开默认收起的 `Debug`,选择 `Output Type` 和本次执行使用的模型。 8. 选择一张图片或一个视频文件。视频可设置从头截取的帧数,然后点击 `Generate Output`。 -9. 测试背景剔除时,确认 `Host > Models` 中的 `BEN2 Base` 已就绪,然后在 `Debug` 的 +9. 测试背景剔除时,确认 `AI Backend > Models` 中的 `BEN2 Base` 已就绪,然后在 `Debug` 的 `BEN2 Removal Test` 区域选择单张图片并点击 `Remove Background`。 完成后会打开 job 文件夹,其中的 `foreground.png` 带透明 Alpha;状态栏显示 BEN2 推理、模型加载与任务总耗时。当前测试固定关闭较慢的 foreground refinement。 ## Image Empty 去背景 -1. 确认 `Host > Models` 中的 `BEN2 Base` 已就绪;首次安装 Runtime 时会默认下载。 +1. 确认 `AI Backend > Models` 中的 `BEN2 Base` 已就绪;首次安装 Backend 时会默认下载。 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`,使用前需要在 - `Host > Models` 下载所选模型,并固定使用 `process_res_method="upper_bound_resize"`;设备和参考视图策略 + `AI Backend > Models` 下载所选模型,并固定使用 `process_res_method="upper_bound_resize"`;设备和参考视图策略 继续使用场景设置。Model 下拉菜单只列出已经下载的 DA3 模型;若没有可用模型,对话框会引导到 - `Host > Models` 下载。 + `AI Backend > Models` 下载。 - `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,不在 Host 中改写深度值。保存 `.foreground` 时保留 BEN2 Alpha,但 RGB 使用未经修改的原始输入; + Mask,不在 Backend Service 中改写深度值。保存 `.foreground` 时保留 BEN2 Alpha,但 RGB 使用未经修改的原始输入; 因此材质直接用 `.foreground` 同时提供颜色和 Mask,不再额外加载原图 Image Texture。 Image Empty 引用视频或序列帧时,AI Generated 会按 Empty 的 Image Frames 数值逐帧估计 深度(未设置时为完整帧数,显式设为 1 时只处理 1 帧),起始帧跟随 Empty 的 Offset,与背景蒙版范围一致; @@ -139,7 +139,7 @@ Cyclic 沿用原 Empty 的设置。序列帧无法被 Blender 整体 Pack,因 在节点组内沿法线修改网格,`Material` 保持网格平面并启用材质置换; - `Subdivision`:位于第二项,默认 `5`。节点读取基础平面的 Bounding Box,短边分段数为 `2^Subdivision`, 长边按宽高比计算并四舍五入;例如 16:9 图片在等级 `5` 时生成 `57×32` 个面; - - `Background Depth`:默认 `0`,允许正负值,Structure Type 为 `Single`,直接显示且不再放在 Panel 中。前景 Mask 外的结构或 + - `Background Depth`:默认 `-1`,允许正负值,Structure Type 为 `Single`,直接显示且不再放在 Panel 中。前景 Mask 外的结构或 材质置换使用该值;是否存在背景只取决于转换所得色彩贴图的 `Alpha > 0.5` 二值结果; - `Depth Scale`:默认 `1`,允许正负值,Structure Type 为 `Single`,同时控制 Geometry 与 Material 两条置换路径。 @@ -167,11 +167,11 @@ Cyclic 沿用原 Empty 的设置。序列帧无法被 Blender 整体 Pack,因 `Convert to Depth Plane`,当前不处理视频或序列帧。 对话框中的 Resolution Scale 与 Depth Plane 使用相同设计:原图长边乘以 `1–100%` 的比例后, 将实际 Process Res 钳制在 `252–1008` 并按 `14` 对齐。 -2. Host 请求 DA3 的 `depth`、`conf` 与 `intrinsics`。仅在开启 `Remove Background` 时运行 BEN2 取得 - 前景 Alpha,并在输出前删除包含背景顶点的三角面;关闭时不加载 BEN2。Depth 不做 smoothing,Host +2. Backend Service 请求 DA3 的 `depth`、`conf` 与 `intrinsics`。仅在开启 `Remove Background` 时运行 BEN2 取得 + 前景 Alpha,并在输出前删除包含背景顶点的三角面;关闭时不加载 BEN2。Depth 不做 smoothing,Backend Service 反投影深度网格,建立源三角面,计算 confidence percentile 与 camera-space edge stretch,并写入 基于原图像素坐标的源 `UVMap`。这些步骤只准备修改器的输入网格,不执行 Relax、拓扑修复、平滑、 - 小岛清理或最终 UV 重投影。运行时显示的六个 `Preparing surface data` 步骤均指这段 Host 端源数据准备。 + 小岛清理或最终 UV 重投影。运行时显示的六个 `Preparing surface data` 步骤均指这段 Backend 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. Host 准备过程固定显示 6 个编号阶段:验证 DA3 数据、反投影、准备 DA3 与可选 BEN2 数据、构建原始 +5. Backend Service 准备过程固定显示 6 个编号阶段:验证 DA3 数据、反投影、准备 DA3 与可选 BEN2 数据、构建原始 源三角面、源 UV/attributes、写出网格。状态栏显示 `Preparing surface data n/6: ...`。 -6. Host 在 job 根目录写出 `mesh.npz` 与 `texture.png`。NPZ 保存 camera-space `vertices`、 +6. Backend 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`。背景移除默认关闭;开启后 Host + `Remove Background`、`Unshaded`,以及分割线下方的 `Keep Original`。背景移除默认关闭;开启后 Backend 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。当前 所有任务使用统一的状态栏进度显示。变化中的阶段说明显示在右侧的进度条内部,取消按钮 位于进度条右侧。状态字段支持阶段、当前操作与预计剩余秒数,界面统一组合为紧凑文案并保证 -进度单调递增。任务进程与 Host 请求在后台启动,按钮点击后立即进入进度状态。Image Depth 的阶段包括 Runtime 启动、输入准备、模型加载、深度估算、缓存 +进度单调递增。任务进程与 Backend Service 请求在后台启动,按钮点击后立即进入进度状态。Image Depth 的阶段包括 Backend Environment 启动、输入准备、模型加载、深度估算、缓存 写出和面片构建。进度更新会强制刷新状态栏与窗口,无需移动鼠标即可实时显示。安装中断后残留的 -半成品 Runtime 会在下次安装前自动清理,不会因为中断而无法再次安装。 +半成品 Backend Environment 会在下次安装前自动清理,不会因为中断而无法再次安装。 导入过程精确保留 NPZ 内深度值之间的差值。例如深度 `2.0` 和 `5.5` 在 `Displacement Strength = -1` 时,沿局部 Z 轴相差 `3.5 Blender Unit`。它不会把数值压缩到 @@ -393,9 +393,15 @@ Point Cloud GLB 任务会关闭其默认附带的深度预览,使五种输出 官方格式与限制可参阅 [DA3 Python API 文档](https://github.com/ByteDance-Seed/Depth-Anything-3/blob/main/docs/API.md)。 -## Runtime +## AI Backend -Extension 本身不包含 Torch。`Install Runtime` 使用 `uv` 创建独立 Python 3.12 +AI Backend 是统一上位概念,由两部分组成:Backend Environment 是磁盘上的 Python、依赖和模型执行环境; +Backend Service 是使用该环境启动、接收 Blender 任务的常驻 HTTP 进程。环境的 +`environment_manifest.json` 使用 `environment_manifest_revision` 判断安装配方是否匹配;Service 的 +`/health` 使用 `job_protocol_version` 判断 Blender 与服务之间的任务协议是否匹配。两者生命周期不同, +因此分别维护版本,而不合并成一个 Schema。 + +Extension 本身不包含 Torch。`Install Backend` 使用 `uv` 创建独立 Python 3.12 环境,并安装。机器上即使没有安装 `uv` 或 Python 也没关系:安装程序由 Blender 自带的 Python 启动,缺 `uv` 时会解包扩展内置的对应平台二进制,再用它下载 Python 3.12 与全部依赖。`uv` 会缓存在扩展存储目录下,后续安装直接复用。 @@ -427,12 +433,12 @@ Windows 上代理若只在系统设置里而未写入环境变量,安装程序 并核对覆盖文件是否仍适用 - `addict` 2.4.0(补充 DA3 上游包元数据暂未声明的运行依赖) -安装 Runtime 时会自动选择 PyTorch 后端:检测到 NVIDIA 驱动则安装 CUDA 12.8 版本,否则安装 +安装 Backend 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。Runtime 安装完成时会检测真实 -可用的设备并写入 `snapform_ready.json`,设备列表以该检测结果为准。 +Windows 不显示 MPS,机器上没有可用 NVIDIA 驱动时不显示 CUDA。Backend Environment 安装完成时会检测真实 +可用的设备并写入 `environment_manifest.json`,设备列表以该检测结果为准。 CUDA 12.8 的 PyTorch wheel 自带 CUDA 运行库,无需安装 CUDA Toolkit;只需要 NVIDIA 驱动 ≥ 570(数据中心显卡可用更老驱动)。驱动不满足或没有 NVIDIA 显卡时, 设备会自动回退到 CPU(macOS 为 MPS),只有显式选择 CUDA 才会报错。 @@ -442,30 +448,30 @@ macOS(Apple Silicon)会自动从 PyPI 安装 CPU 版 PyTorch,推理设备 Mac(x86_64)轮子,Intel Mac 暂不支持。DA3 射线位姿估计中的 3×3 QR 分解在 MPS 上尚未实现,SnapForm 的覆盖文件会单独把该分解放到 CPU 执行,其余推理仍走 MPS。 -Extension 启用后只会查找已有的共享 AI Host,不会主动启动新进程。第一次执行 AI 操作或在侧边栏 -手动点击 `Start` 时才会启动 Host。Host 只绑定回环地址,并让操作系统分配动态端口;第一个实例把 +Extension 启用后只会查找已有的共享 Backend Service,不会主动启动新进程。第一次执行 AI 操作或在侧边栏 +手动点击 `Start` 时才会启动 Backend Service。Backend Service 只绑定回环地址,并让操作系统分配动态端口;第一个实例把 端口、进程和随机 Token 写入 `host.json`,其余 Blender 通过标准库 -`urllib.request` 连接同一个 Host。`host.lock` 保证同一 Storage Root 同时只有一个 Host,Token -则阻止同机其他进程直接调用接口。Host 全局同时只执行一个 AI 任务。 +`urllib.request` 连接同一个 Backend Service。`host.lock` 保证同一 Storage Root 同时只有一个 Backend Service,Token +则阻止同机其他进程直接调用接口。Backend Service 全局同时只执行一个 AI 任务。 -每个 Blender 会注册独立客户端并每 5 秒发送心跳。正常关闭 Blender 时先注销自身;如果当前 Host -就是由这个 Blender 启动的,会直接终止它,无需等待。异常退出的客户端会在 10 秒后过期;Host -在最后一个客户端离开后仍会保留模型缓存等待 20 秒再退出,作为 Host 由其他进程启动等场景的兜底。 -取消任务不会重启 Host,并会保留已经加载的模型; +每个 Blender 会注册独立客户端并每 5 秒发送心跳。正常关闭 Blender 时先注销自身;如果当前 Backend Service +就是由这个 Blender 启动的,会直接终止它,无需等待。异常退出的客户端会在 10 秒后过期;Backend Service +在最后一个客户端离开后仍会保留模型缓存等待 20 秒再退出,作为 Service 由其他进程启动等场景的兜底。 +取消任务不会重启 Backend Service,并会保留已经加载的模型; 如果取消发生在一次不可中断的 Torch 调用中,任务会在该调用返回后的最近检查点结束。 -侧边栏 `Host` 面板以 `Running (Clients: n)` 表示空闲服务,以 `Running (Busy)` 表示正在执行任务, +侧边栏 `AI Backend` 面板以 `Running (Clients: n)` 表示空闲服务,以 `Running (Busy)` 表示正在执行任务, 并显示缓存模型和活动任务。Blender 完成结果导入时会立即释放当前任务对应的本地 Busy 快照,无需等待 -下一次 Host 轮询。空闲且只有当前 Blender 连接时,可以清理模型缓存、重启或停止 Host; +下一次 Backend Service 轮询。空闲且只有当前 Blender 连接时,可以清理模型缓存、重启或停止 Service; 多客户端或任务执行期间不会允许破坏共享服务。 BEN2 在首次使用后保持加载;DA3 保留最近使用的一个模型。切换 DA3 模型时会释放旧模型;加载时如果 显存不足,会先释放另一类缓存模型并重试。 -Runtime Schema 已升级,旧 Runtime 需要重新执行 `Install Runtime` 才能安装 HTTP Host 依赖。 -Host 的启动和崩溃日志写入 `~/.snapform/ai-host.log`。 +Environment Manifest Revision 变化时,旧 Backend Environment 需要重新执行 `Install Backend`。 +Backend Service 的启动和崩溃日志写入 `~/.snapform/ai-host.log`。 -Runtime、模型和任务结果默认保存在用户主目录的 `~/.snapform`: +Backend Environment、模型和任务结果默认保存在用户主目录的 `~/.snapform`: ```text ~/.snapform/ @@ -473,8 +479,8 @@ Runtime、模型和任务结果默认保存在用户主目录的 `~/.snapform` ├── models/ ├── jobs/ ├── ai-host.log -├── host.json # Host 运行时存在 -└── host.lock # Host 运行时持有 +├── host.json # Backend Service 运行时存在 +└── host.lock # Backend Service 运行时持有 ``` `models/` 下每个模型一个文件夹,命名统一带前缀:DA3 为 `da3_*`(如 `da3_small`、`da3_base`), @@ -483,7 +489,7 @@ BEN2 为 `ben2_*`(如 `ben2_base`)。旧版无前缀的 DA3 文件夹会在 不同 Blender 版本安装相同 Extension 后会共用这个目录。也可以在 Extension Preferences 的 `Storage Root` 中修改共享位置。不要把模型写入 Extension 安装目录, 因为该目录可能只读,并且升级时会被替换。不要同时从多个 Blender 实例执行 -`Install Runtime`;推理 Host、任务队列和模型读取可以共享。 +`Install Backend`;Backend Service、任务队列和模型读取可以共享。 ## 开发 diff --git a/src/snapform/__init__.py b/src/snapform/__init__.py index 416b211..fa7aa87 100644 --- a/src/snapform/__init__.py +++ b/src/snapform/__init__.py @@ -60,6 +60,7 @@ 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() @@ -69,6 +70,7 @@ def unregister(): runtime.unschedule_host() runtime.detach_host_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) if hasattr(bpy.types.Scene, "snapform_settings"): del bpy.types.Scene.snapform_settings diff --git a/src/snapform/ai_host.py b/src/snapform/ai_host.py index 82b3f0f..bcb1df2 100644 --- a/src/snapform/ai_host.py +++ b/src/snapform/ai_host.py @@ -19,7 +19,7 @@ if str(SCRIPT_DIRECTORY) not in sys.path: import worker -HOST_SCHEMA = 2 +JOB_PROTOCOL_VERSION = 2 CLIENT_TIMEOUT_SECONDS = 10.0 IDLE_SHUTDOWN_SECONDS = 20.0 @@ -212,7 +212,7 @@ class HostState: with self.lock: return { "ready": True, - "host_schema": HOST_SCHEMA, + "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 +265,7 @@ class HostState: worker.write_status( status_path, 0.0, - "AI Host accepted the task", + "Backend Service accepted the task", state="running", ) try: @@ -372,7 +372,7 @@ def create_app(token, instance_id="test"): state.close() app = FastAPI( - title="SnapForm AI Host", + title="SnapForm Backend Service", docs_url=None, redoc_url=None, openapi_url=None, @@ -445,7 +445,7 @@ def create_app(token, instance_id="test"): command, parameters["status"], ): - raise HTTPException(status_code=409, detail="AI Host is busy") + raise HTTPException(status_code=409, detail="Backend Service is busy") worker.write_status( parameters["status"], 0.0, @@ -485,7 +485,7 @@ 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="AI Host is busy") + raise HTTPException(status_code=409, detail="Backend Service is busy") return {"state": "cleared"} @app.post("/shutdown", status_code=202) @@ -498,7 +498,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="AI Host is busy") + raise HTTPException(status_code=409, detail="Backend Service is busy") if result != "accepted": raise HTTPException(status_code=404, detail="Client is not registered") return {"state": "stopping"} @@ -524,7 +524,7 @@ def write_discovery(args): temporary.write_text( json.dumps( { - "host_schema": HOST_SCHEMA, + "job_protocol_version": JOB_PROTOCOL_VERSION, "port": args.port, "token": args.token, "pid": os.getpid(), diff --git a/src/snapform/assets/O_SnapForm.blend b/src/snapform/assets/O_SnapForm.blend index e4c8b9b..14d377f 100644 --- a/src/snapform/assets/O_SnapForm.blend +++ b/src/snapform/assets/O_SnapForm.blend @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6113e68d9267b1893fecae4e97f01f00a71901992fe3384f989c6222fd6e6460 +oid sha256:79f05ffe742cc2b4a8c184b4e829d4fc4df5824f0d11ca12d4d1d566b93ec6be size 917173 diff --git a/src/snapform/blender_manifest.toml b/src/snapform/blender_manifest.toml index 0699e33..a89b63f 100644 --- a/src/snapform/blender_manifest.toml +++ b/src/snapform/blender_manifest.toml @@ -12,7 +12,7 @@ platforms = ["windows-x64", "macos-arm64", "linux-x64"] license = ["SPDX:GPL-3.0-or-later"] [permissions] -network = "Access the local AI Host and download runtime dependencies and model weights" +network = "Access the local Backend Service and download environment dependencies and model weights" files = "Read source images and store runtime data" [build] diff --git a/src/snapform/bootstrap_runtime.py b/src/snapform/bootstrap_runtime.py index 143bd2b..d36dab6 100644 --- a/src/snapform/bootstrap_runtime.py +++ b/src/snapform/bootstrap_runtime.py @@ -34,7 +34,8 @@ XFORMERS_VERSION = "0.0.33.post2" ADDICT_VERSION = "2.4.0" FASTAPI_VERSION = "0.139.2" UVICORN_VERSION = "0.51.0" -RUNTIME_SCHEMA = 4 +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", @@ -429,14 +430,17 @@ def install_stages(args, runtime_dir, python, uv): def runtime_ready(runtime_dir): - ready_path = runtime_dir / "snapform_ready.json" + ready_path = runtime_dir / ENVIRONMENT_MANIFEST_FILENAME if not environment_python(runtime_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("runtime_schema") == RUNTIME_SCHEMA + return ( + payload.get("environment_manifest_revision") + == ENVIRONMENT_MANIFEST_REVISION + ) def installed_devices(args): @@ -505,7 +509,7 @@ def run_verify( write_status( status_path, progress, - "Checking Runtime", + "Checking Backend Environment", stage=stage, stages=stages, stage_label=stage_label, @@ -777,11 +781,13 @@ def install(args): installed_packages.extend(stage_installed) devices = installed_devices(args) runtime_dir.mkdir(parents=True, exist_ok=True) - ready_path = runtime_dir / "snapform_ready.json" + ready_path = runtime_dir / ENVIRONMENT_MANIFEST_FILENAME ready_path.write_text( json.dumps( { - "runtime_schema": RUNTIME_SCHEMA, + "environment_manifest_revision": ( + ENVIRONMENT_MANIFEST_REVISION + ), "python": "3.12", "torch": TORCH_VERSION, "xformers": XFORMERS_VERSION, @@ -828,10 +834,14 @@ def install(args): write_status( args.status, 1.0, - f"Runtime is ready; retry {labels} from Models", + f"Backend Environment is ready; retry {labels} from Models", ) return - write_status(args.status, 1.0, "Runtime and recommended models ready") + write_status( + args.status, + 1.0, + "Backend Environment and recommended models ready", + ) def parse_args(): @@ -852,7 +862,12 @@ def main(): try: install(args) except Exception as error: - write_status(args.status, 1.0, "Runtime installation failed", str(error)) + write_status( + args.status, + 1.0, + "Backend Environment installation failed", + str(error), + ) raise diff --git a/src/snapform/image_depth.py b/src/snapform/image_depth.py index 668aad2..99af4f0 100644 --- a/src/snapform/image_depth.py +++ b/src/snapform/image_depth.py @@ -1823,7 +1823,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 Host > Models") + estimate_box.label(text="Download one in AI Backend > Models") else: estimate_box.prop(self, "snapform_model") estimate_box.prop(self, "snapform_use_ray_pose") @@ -1917,8 +1917,18 @@ class SnapFormImageMenu(bpy.types.Menu): bl_label = "SnapForm" def draw(self, _context): + from .runtime import runtime_ready + layout = self.layout layout.operator_context = "INVOKE_DEFAULT" + if not runtime_ready(): + operator = layout.operator( + "snapform.run_job", + text="Install Backend", + icon="IMPORT", + ) + operator.job_type = "INSTALL_RUNTIME" + return layout.operator( "snapform.convert_image_to_depth_plane", text="Convert to Depth Plane", diff --git a/src/snapform/operators.py b/src/snapform/operators.py index bac1c6a..34af474 100644 --- a/src/snapform/operators.py +++ b/src/snapform/operators.py @@ -22,6 +22,22 @@ BACKGROUND_REMOVAL_JOB = "REMOVE_BACKGROUND" IMAGE_BACKGROUND_JOB = "IMAGE_BACKGROUND" ORIGINAL_BACKGROUND_JOB = "ORIGINAL_BACKGROUND" ORIGINAL_FRAMES_JOB = "ORIGINAL_FRAMES" +DA3_IMAGE_JOBS = frozenset({IMAGE_DEPTH_JOB, IMAGE_MESH_JOB}) +IMAGE_EMPTY_JOBS = frozenset( + { + *DA3_IMAGE_JOBS, + IMAGE_BACKGROUND_JOB, + ORIGINAL_BACKGROUND_JOB, + ORIGINAL_FRAMES_JOB, + } +) +PRODUCTION_AI_JOBS = frozenset( + { + *DA3_IMAGE_JOBS, + IMAGE_BACKGROUND_JOB, + ORIGINAL_BACKGROUND_JOB, + } +) IMAGE_DEPTH_DEFAULT_PROCESS_RES = 1024 IMAGE_DEPTH_PROCESS_RES_METHOD = "upper_bound_resize" BEN2_IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".webp"} @@ -134,7 +150,7 @@ class RunJob(bpy.types.Operator): job_type: bpy.props.EnumProperty( items=( - ("INSTALL_RUNTIME", "Install Runtime", ""), + ("INSTALL_RUNTIME", "Install Backend", ""), ("DOWNLOAD_MODEL", "Download Model", ""), (RECONSTRUCTION_JOB, "Export DA3 Result", ""), (IMAGE_DEPTH_JOB, "Convert Image to Depth Plane", ""), @@ -242,13 +258,7 @@ class RunJob(bpy.types.Operator): ), temporary_input_path=( Path(self.snapform_input_path) - if self.job_type in { - IMAGE_DEPTH_JOB, - IMAGE_MESH_JOB, - IMAGE_BACKGROUND_JOB, - ORIGINAL_BACKGROUND_JOB, - ORIGINAL_FRAMES_JOB, - } + if self.job_type in IMAGE_EMPTY_JOBS and self.snapform_delete_input else None ), @@ -311,13 +321,7 @@ class RunJob(bpy.types.Operator): paths = runtime.new_job_paths(self.job_type) if ( getattr(self, "snapform_delete_input", False) - and self.job_type in { - IMAGE_DEPTH_JOB, - IMAGE_MESH_JOB, - IMAGE_BACKGROUND_JOB, - ORIGINAL_BACKGROUND_JOB, - ORIGINAL_FRAMES_JOB, - } + and self.job_type in IMAGE_EMPTY_JOBS ): archived_input = runtime.move_input_into_job( self.snapform_input_path, @@ -333,7 +337,7 @@ class RunJob(bpy.types.Operator): return runtime.install_runtime_command(paths["status"]), paths if not runtime.runtime_ready(): - raise RuntimeError("Install the SnapForm Runtime first") + raise RuntimeError("Install the SnapForm Backend first") if self.job_type == "DOWNLOAD_MODEL": self._require_online_access() @@ -343,13 +347,7 @@ class RunJob(bpy.types.Operator): paths, ) - if self.job_type in { - IMAGE_DEPTH_JOB, - IMAGE_MESH_JOB, - IMAGE_BACKGROUND_JOB, - ORIGINAL_BACKGROUND_JOB, - ORIGINAL_FRAMES_JOB, - }: + if self.job_type in IMAGE_EMPTY_JOBS: configured_input = self.snapform_input_path elif self.job_type == BACKGROUND_REMOVAL_JOB: configured_input = settings.background_input_path @@ -413,7 +411,7 @@ class RunJob(bpy.types.Operator): use_ray_pose = None process_res = None model_key = selected_model_key(settings, export_format) - if self.job_type in {IMAGE_DEPTH_JOB, IMAGE_MESH_JOB}: + if self.job_type in DA3_IMAGE_JOBS: model_key = self.snapform_model_key use_ray_pose = self.snapform_use_ray_pose process_res = self.snapform_process_res @@ -421,7 +419,7 @@ class RunJob(bpy.types.Operator): if not runtime.model_ready(model_key): raise RuntimeError(f"Download {model.label} first") if ( - self.job_type in {IMAGE_DEPTH_JOB, IMAGE_MESH_JOB} + self.job_type in DA3_IMAGE_JOBS and remove_background and not runtime.model_ready(BEN2_MODEL.key) ): @@ -441,13 +439,13 @@ class RunJob(bpy.types.Operator): process_res=process_res, process_res_method=( IMAGE_DEPTH_PROCESS_RES_METHOD - if self.job_type in {IMAGE_DEPTH_JOB, IMAGE_MESH_JOB} + if self.job_type in DA3_IMAGE_JOBS else None ), use_ray_pose=use_ray_pose, start_frame=start_frame, remove_background=( - self.job_type in {IMAGE_DEPTH_JOB, IMAGE_MESH_JOB} + self.job_type in DA3_IMAGE_JOBS and remove_background ), device=self._task_device(settings), @@ -457,12 +455,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 { - IMAGE_DEPTH_JOB, - IMAGE_MESH_JOB, - IMAGE_BACKGROUND_JOB, - ORIGINAL_BACKGROUND_JOB, - }: + if self.job_type in PRODUCTION_AI_JOBS: return runtime.production_device() return settings.device @@ -474,7 +467,7 @@ class RunJob(bpy.types.Operator): def _starting_message(self, settings): labels = { - "INSTALL_RUNTIME": "Starting Runtime installation", + "INSTALL_RUNTIME": "Starting Backend installation", "DOWNLOAD_MODEL": "Starting model download", } if self.job_type == RECONSTRUCTION_JOB: @@ -612,7 +605,7 @@ class RunJob(bpy.types.Operator): settings.status = "Task failed" settings.progress = 0.0 fallback_error = ( - "AI Host task failed" + "Backend Service task failed" if job.uses_host else "Task subprocess failed" ) @@ -845,15 +838,15 @@ class CancelJob(bpy.types.Operator): class ControlHost(bpy.types.Operator): bl_idname = "snapform.control_host" - bl_label = "Control SnapForm AI Host" + bl_label = "Control SnapForm Backend Service" action: bpy.props.EnumProperty( items=( - ("START", "Start", "Start or connect to the shared AI Host"), + ("START", "Start", "Start or connect to the Backend Service"), ("CLEAR", "Clear Models", "Release cached AI models"), - ("RESTART", "Restart", "Restart the shared AI Host"), - ("STOP", "Stop", "Stop the shared AI Host"), - ("LOG", "Open Log", "Open the AI Host log"), + ("RESTART", "Restart", "Restart the Backend Service"), + ("STOP", "Stop", "Stop the Backend Service"), + ("LOG", "Open Log", "Open the Backend Service log"), ), options={"HIDDEN", "SKIP_SAVE"}, ) @@ -862,22 +855,22 @@ class ControlHost(bpy.types.Operator): try: if self.action == "START": runtime.enable_host() - message = "AI Host is ready" + message = "Backend Service is ready" elif self.action == "CLEAR": runtime.clear_host_models() message = "AI model cache is cleared" elif self.action == "RESTART": runtime.restart_host() - message = "AI Host is restarting" + message = "Backend Service is restarting" elif self.action == "STOP": runtime.stop_host() - message = "AI Host is stopping" + message = "Backend Service is stopping" else: log_path = runtime.host_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 AI Host log") + raise RuntimeError("Unable to open the Backend 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 6a64b55..834f3a5 100644 --- a/src/snapform/panel.py +++ b/src/snapform/panel.py @@ -7,7 +7,7 @@ from .runtime import model_ready, runtime_ready class HostPanel(bpy.types.Panel): - bl_label = "Host" + bl_label = "AI Backend" bl_idname = "SNAPFORM_PT_host" bl_space_type = "VIEW_3D" bl_region_type = "UI" @@ -18,12 +18,15 @@ class HostPanel(bpy.types.Panel): layout = self.layout running = active_job() is not None or runtime.host_busy() if not runtime_ready(): - layout.label(text="Runtime is not installed", icon="ERROR") + layout.label( + text="Backend Environment is not installed", + icon="ERROR", + ) install_row = layout.row() install_row.enabled = not running operator = install_row.operator( "snapform.run_job", - text="Install Runtime", + text="Install Backend", icon="IMPORT", ) operator.job_type = "INSTALL_RUNTIME" diff --git a/src/snapform/preferences.py b/src/snapform/preferences.py index f81c949..50eb1c5 100644 --- a/src/snapform/preferences.py +++ b/src/snapform/preferences.py @@ -11,7 +11,7 @@ class SnapFormPreferences(bpy.types.AddonPreferences): storage_root: bpy.props.StringProperty( name="Storage Root", description=( - "Shared user-writable storage for Runtime, models and jobs; " + "Shared user-writable storage for Backend Environment, models and jobs; " "all Blender versions can use the same directory" ), subtype="DIR_PATH", @@ -35,22 +35,22 @@ class SnapFormPreferences(bpy.types.AddonPreferences): layout.prop(self, "storage_root") layout.prop(self, "device") layout.separator() - layout.label(text=f"Runtime: {runtime_directory()}") + layout.label(text=f"Backend Environment: {runtime_directory()}") if runtime_ready(): - layout.label(text="Runtime is ready", icon="CHECKMARK") + layout.label(text="Backend Environment is ready", icon="CHECKMARK") else: install_row = layout.row() install_row.enabled = active_job() is None operator = install_row.operator( "snapform.run_job", - text="Install Runtime", + text="Install Backend", icon="IMPORT", ) operator.job_type = "INSTALL_RUNTIME" layout.separator() operator = layout.operator( "snapform.control_host", - text="Open Host Log", + text="Open Service Log", icon="TEXT", ) operator.action = "LOG" diff --git a/src/snapform/runtime.py b/src/snapform/runtime.py index 6f41061..7ed24ed 100644 --- a/src/snapform/runtime.py +++ b/src/snapform/runtime.py @@ -28,8 +28,9 @@ from .models import ( DA3_COMMIT = "3d835ec1a5802d64a8b8b15f817a1ab54809bfe4" BEN2_COMMIT = "2c99a5da477b5523585bfa5c893888a6e818a8f6" -RUNTIME_SCHEMA = 4 -HOST_SCHEMA = 2 +ENVIRONMENT_MANIFEST_REVISION = 4 +JOB_PROTOCOL_VERSION = 2 +ENVIRONMENT_MANIFEST_FILENAME = "environment_manifest.json" HOST_ADDRESS = "127.0.0.1" DEFAULT_STORAGE_ROOT = Path.home() / ".snapform" JOB_FOLDER_NAMES = { @@ -103,14 +104,17 @@ def runtime_python(): def runtime_ready(): - ready_path = runtime_directory() / "snapform_ready.json" + ready_path = runtime_directory() / ENVIRONMENT_MANIFEST_FILENAME if not runtime_python().is_file() or not ready_path.is_file(): return False try: ready = json.loads(ready_path.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError): return False - return ready.get("runtime_schema") == RUNTIME_SCHEMA + return ( + ready.get("environment_manifest_revision") + == ENVIRONMENT_MANIFEST_REVISION + ) def models_directory(): @@ -140,8 +144,8 @@ def model_ready(model_key): def runtime_devices(): - """Devices detected by the installed Runtime, or None when unknown.""" - ready_path = runtime_directory() / "snapform_ready.json" + """Devices detected by the Backend Environment, or None when unknown.""" + ready_path = runtime_directory() / ENVIRONMENT_MANIFEST_FILENAME if not ready_path.is_file(): return None try: @@ -155,7 +159,7 @@ def runtime_devices(): def machine_devices(): - """Devices the machine can run, fastest first; used before Runtime install.""" + """Devices the machine can run before Backend Environment installation.""" devices = ["cpu"] if shutil.which("nvidia-smi"): devices.insert(0, "cuda") @@ -329,9 +333,11 @@ def _host_http_request(connection, method, path, payload=None, timeout=2.0): detail = json.loads(error.read().decode("utf-8")).get("detail") except (json.JSONDecodeError, UnicodeDecodeError): detail = None - raise RuntimeError(detail or f"AI Host returned HTTP {error.code}") + raise RuntimeError( + detail or f"Backend Service returned HTTP {error.code}" + ) except (OSError, URLError) as error: - raise RuntimeError(f"Unable to reach AI Host: {error}") + raise RuntimeError(f"Unable to reach Backend Service: {error}") def _read_host_discovery(): @@ -354,10 +360,10 @@ def _check_host(connection, timeout=0.3): "/health", timeout=timeout, ) - if health.get("host_schema") != HOST_SCHEMA: - raise RuntimeError("AI Host protocol is out of date") + 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("AI Host discovery is stale") + raise RuntimeError("Backend Service discovery is stale") return health @@ -439,7 +445,9 @@ def ensure_host(timeout=60.0): start_host() last_launch = time.monotonic() time.sleep(0.05) - raise RuntimeError(f"AI Host did not become ready; log: {host_log_path()}") + raise RuntimeError( + f"Backend Service did not become ready; log: {host_log_path()}" + ) def submit_host_job(request): @@ -485,7 +493,7 @@ def stop_host(): break time.sleep(0.05) else: - raise RuntimeError("AI Host did not stop") + raise RuntimeError("Backend Service did not stop") _host_connection = None _registered_instance_id = None _close_finished_launched_host() @@ -563,7 +571,7 @@ def detach_host_client(): pass _host_connection = None _registered_instance_id = None - # 如果 Host 是由当前 Blender 启动的,关闭 Blender 时直接终止它,无需等待空闲超时 + # 当前 Blender 启动的 Service 随 Blender 关闭,不等待空闲超时。 _close_launched_host(terminate=True) @@ -616,7 +624,7 @@ def _service_host_timer(): if not runtime_ready(): _host_snapshot = { "state": "UNAVAILABLE", - "message": "Runtime is not installed", + "message": "Backend Environment is not installed", } return 2.0 connection, health = _discover_live_host(timeout=0.2) diff --git a/src/snapform/worker.py b/src/snapform/worker.py index 92acc6d..5becf32 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 BEN2 Runtime") + write_status(args.status, 0.02, "Starting Backend 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 AI runtime") + write_status(args.status, 0.02, "Starting Backend Environment") check_cancelled(cancel_check) if model_cache is None: diff --git a/tests/test_addon.py b/tests/test_addon.py index f333eaf..430b134 100644 --- a/tests/test_addon.py +++ b/tests/test_addon.py @@ -43,10 +43,15 @@ class AddonTest(unittest.TestCase): append=lambda draw: cls.context_menu_draws.append(draw), remove=lambda draw: cls.context_menu_draws.remove(draw), ), + OUTLINER_MT_object=SimpleNamespace( + append=lambda draw: cls.outliner_menu_draws.append(draw), + remove=lambda draw: cls.outliner_menu_draws.remove(draw), + ), ) cls.registered = [] cls.status_bar_draws = [] cls.context_menu_draws = [] + cls.outliner_menu_draws = [] fake_bpy.utils = SimpleNamespace( register_class=cls.registered.append, unregister_class=cls.registered.remove, @@ -80,6 +85,7 @@ class AddonTest(unittest.TestCase): self.registered.clear() self.status_bar_draws.clear() self.context_menu_draws.clear() + self.outliner_menu_draws.clear() self.addon.operators._active_job = None self.addon.image_depth._last_depth_plane_model = "BASE" self.addon.image_mesh._last_mesh_model = "BASE" @@ -107,6 +113,10 @@ class AddonTest(unittest.TestCase): self.context_menu_draws, [self.addon.draw_image_context_menu], ) + self.assertEqual( + self.outliner_menu_draws, + [self.addon.draw_image_context_menu], + ) self.addon.unregister() @@ -116,6 +126,7 @@ class AddonTest(unittest.TestCase): ) self.assertEqual(self.status_bar_draws, []) self.assertEqual(self.context_menu_draws, []) + self.assertEqual(self.outliner_menu_draws, []) def test_registers_depth_plane_operator_without_cache_rebuild(self): self.assertIn( @@ -149,7 +160,8 @@ class AddonTest(unittest.TestCase): menu = self.addon.SnapFormImageMenu() menu.layout = Layout() - menu.draw(None) + with patch.object(self.addon.runtime, "runtime_ready", return_value=True): + menu.draw(None) self.assertEqual( calls, @@ -161,6 +173,34 @@ class AddonTest(unittest.TestCase): ], ) + def test_image_context_menu_only_installs_backend_when_missing(self): + calls = [] + + class Operator: + job_type = None + + class Layout: + operator_context = None + + def operator(self, identifier, **options): + operator = Operator() + calls.append((identifier, options, operator)) + return operator + + def separator(self): + calls.append("separator") + + menu = self.addon.SnapFormImageMenu() + menu.layout = Layout() + with patch.object(self.addon.runtime, "runtime_ready", 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") + def test_image_mesh_reference_plane_matches_image_empty_bounds(self): source_object = SimpleNamespace( data=SimpleNamespace(size=(401, 201)), @@ -1945,13 +1985,13 @@ class AddonTest(unittest.TestCase): 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, "Host") + self.assertEqual(self.addon.HostPanel.bl_label, "AI Backend") def test_ai_operation_restarts_a_manually_stopped_host(self): runtime = self.addon.runtime connection = runtime.HostConnection(51234, "token", 42, "host") health = { - "host_schema": runtime.HOST_SCHEMA, + "job_protocol_version": runtime.JOB_PROTOCOL_VERSION, "instance_id": "host", "busy": False, "clients": 1, diff --git a/tests/test_bootstrap_runtime.py b/tests/test_bootstrap_runtime.py index ada2b21..1a54d2d 100644 --- a/tests/test_bootstrap_runtime.py +++ b/tests/test_bootstrap_runtime.py @@ -689,7 +689,7 @@ class BootstrapRuntimeTest(unittest.TestCase): bootstrap.install(args) ready = json.loads( - (runtime_dir / "snapform_ready.json").read_text( + (runtime_dir / "environment_manifest.json").read_text( encoding="utf-8" ) ) @@ -730,7 +730,9 @@ class BootstrapRuntimeTest(unittest.TestCase): bootstrap.install(args) self.assertFalse((runtime_dir / "partial-python.exe").exists()) - self.assertTrue((runtime_dir / "snapform_ready.json").is_file()) + self.assertTrue( + (runtime_dir / "environment_manifest.json").is_file() + ) def test_ready_runtime_is_not_cleaned(self): bootstrap = load_bootstrap_runtime() @@ -741,8 +743,14 @@ class BootstrapRuntimeTest(unittest.TestCase): self.create_bundled_vendor(root) (runtime_dir / "Scripts").mkdir() (runtime_dir / "Scripts" / "python.exe").write_text("python") - (runtime_dir / "snapform_ready.json").write_text( - json.dumps({"runtime_schema": bootstrap.RUNTIME_SCHEMA}), + (runtime_dir / "environment_manifest.json").write_text( + json.dumps( + { + "environment_manifest_revision": ( + bootstrap.ENVIRONMENT_MANIFEST_REVISION + ) + } + ), encoding="utf-8", ) (runtime_dir / "marker.txt").write_text("keep") @@ -804,12 +812,14 @@ class BootstrapRuntimeTest(unittest.TestCase): ): bootstrap.install(args) - self.assertTrue((runtime_dir / "snapform_ready.json").is_file()) + self.assertTrue( + (runtime_dir / "environment_manifest.json").is_file() + ) status = json.loads( (root / "status.json").read_text(encoding="utf-8") ) self.assertEqual(status["progress"], 1.0) - self.assertIn("Runtime is ready", status["message"]) + self.assertIn("Backend Environment is ready", status["message"]) self.assertTrue( any( "PramaLLC/BEN2" in map(str, command) @@ -1208,7 +1218,10 @@ class BootstrapRuntimeTest(unittest.TestCase): messages = [call.args[2] for call in mocked_write.call_args_list] self.assertTrue( - any("Checking Runtime" in message for message in messages) + any( + "Checking Backend Environment" in message + for message in messages + ) ) self.assertEqual(report, {}) diff --git a/tools/build_geometry_nodes_asset.py b/tools/build_geometry_nodes_asset.py index e53bff5..db99278 100644 --- a/tools/build_geometry_nodes_asset.py +++ b/tools/build_geometry_nodes_asset.py @@ -708,7 +708,7 @@ def build_depth_plane_group(): "INPUT", "NodeSocketFloat", ) - background_depth.default_value = 0.0 + background_depth.default_value = -1.0 background_depth.min_value = -10000.0 background_depth.max_value = 10000.0 background_depth.subtype = "DISTANCE" diff --git a/tools/validate_depth_plane_asset.py b/tools/validate_depth_plane_asset.py index 3308529..0103b2e 100644 --- a/tools/validate_depth_plane_asset.py +++ b/tools/validate_depth_plane_asset.py @@ -29,6 +29,7 @@ def main(): ) assert background_depth.subtype == "DISTANCE" assert background_depth.structure_type == "SINGLE" + assert background_depth.default_value == -1.0 frame_input = next( item for item in group.interface.items_tree