Add reusable local HTTP job service for Blender add-ons

- Implement JobClient for handling HTTP requests to the JobServer.
- Create ModalJobOperatorBase for managing Blender job lifecycle.
- Introduce JobContext and JobServer for job management and state handling.
- Add ServiceConnection and ServiceController for managing service processes.
- Implement job submission, cancellation, and resource management endpoints in the server.
- Ensure proper error handling and job state updates throughout the service.
This commit is contained in:
2026-08-16 21:04:10 +08:00
parent 0c8d3a8988
commit b3a07cde32
43 changed files with 2600 additions and 2671 deletions

View File

@@ -75,10 +75,7 @@ Session 逐帧推理,显存峰值不随视频帧数平方增长;Debug 多帧
## 任务结果保存在哪里
任务结果保存在 `~/.snapform/jobs/` 下,每个任务一个文件夹,名称统一为
`动作-对象_日期-时间-微秒`。Packed Image 和序列帧的输入副本会直接归档到对应任务根目录,
不再使用公共 `inputs/` 文件夹;导出产物也尽量平铺。视频或序列帧生成的面片会引用任务文件夹里的
序列帧,请保留对应文件夹,不要随意删除。
任务结果保存在 `~/.snapform/jobs/` 下。Job Server 接受任务时自动生成 opaque Job ID,并创建同名文件夹;Blender 不预先申请 ID 或目录。Packed Image 和序列帧的临时输入由 Blender 在任务结束后清理,Service 产物直接写入该 Job 目录。视频或序列帧生成的面片会引用其中的序列帧,请保留对应文件夹,不要随意删除。
## 常见问题
@@ -90,4 +87,7 @@ Session 逐帧推理,显存峰值不随视频帧数平方增长;Debug 多帧
## 技术文档
AI、帧提取与后续模型下载统一提交给当前 Blender 专属的 Job Server。DA3、MoGe-2 与 Debug 的 Request 和 Job Type 分别一一对应,不使用混合 Backend Request。BlendJob 接受任务时创建无类型前缀的 opaque-ID 工作目录,并通过 `JobContext` 交给执行逻辑。运行中进度通过 HTTP 轮询,已加载的 ONNX Session 由 Server 的 Models Resource 统一查询与清理。Environment 安装仍由不依赖 Service 的 Bootstrap 流程完成,不创建 Job 目录。
详细功能与技术内容见 [docs/index.md](docs/index.md)。
可复用的本地 Job Service API 与插件接入方式见 [docs/blendjob.md](docs/blendjob.md)。

179
docs/blendjob.md Normal file
View File

@@ -0,0 +1,179 @@
# BlendJob 开发者指南
## 命名与边界
通用 module 命名为 `blendjob`。这个名字表达的是 Blender 插件与独立 Job 进程之间的桥接,不绑定 AI、模型框架或 SnapForm。
- `JobClient`:运行在 Blender Python 中,通过 HTTP 提交、查询和取消 Job
- `JobServer`:对 FastAPI 的通用封装,负责 Job 注册、单任务调度、状态与取消
- `JobContext`:单个 Job 的运行上下文,提供工作目录、进度、取消与 Resource
- `ModalJobOperatorBase`:可直接继承的 Blender Modal Operator,内置提交线程、轮询、进度、取消和终态处理
- `Resource`:由 Job Server 持有、跨多个 Job 复用的状态对象
- `SnapForm Job Server`:SnapForm 创建并注册专属 Job 后得到的服务实例;代码变量使用 `server` 或 `snapform_server`
`blendjob` 不知道模型、输出文件或 Blender Operator。插件专属代码负责注册 Job 和 Resource,并把参数交给自己的执行逻辑。
## 最小接入
服务端只创建 Server 并注册业务函数:
```python
server = JobServer("Example", jobs_directory="D:/example/jobs")
@server.job("generate-depth")
def generate_depth(context, parameters):
output = context.directory / "depth.npz"
run_depth(parameters["input"], output)
context.update(1.0, "Depth is ready", output=str(output))
```
Blender 端直接继承一个基类。普通任务只需要声明 Controller、Job Type 和怎么传值:
```python
class GenerateDepth(ModalJobOperatorBase):
bl_idname = "example.generate_depth"
bl_label = "Generate Depth"
controller_factory = staticmethod(service_controller)
job_type = "generate-depth"
def job_parameters(self, context):
return {"input": context.scene.source_path}
```
这个 Operator 已经具备异步提交、Server 生成 Job ID 与目录、HTTP 轮询、进度、Esc 取消、错误报告和 Timer 清理。只有需要把产物导回 Blender 时,才覆盖 `on_job_succeeded(context, job, status)`;产物目录是 `job.directory`。
## Resource
Job 表示一次有终态的操作;Resource 表示在 Service 生命周期内被多个 Job 共用的状态。Resource 可以按需实现 `snapshot()`、`clear()` 和 `close()`:
```python
models = server.add_resource("models", ModelManager(models_directory))
@server.job("generate-depth")
def generate_depth(context, parameters):
models = context.resource("models")
session = models.get(parameters["model"], parameters["device"])
```
`JobServer` 在同一把调度锁下阻止运行中清理 Resource,关闭时按注册逆序调用 `close()`。Resource Snapshot 自动进入 `/health` 的 `resources` 字段。
## 服务端使用
开发者先创建 `JobServer`,再用装饰器注册 Job 类型:
```python
from blendjob import JobCancelled, JobServer
server = JobServer("Example Job Server", jobs_directory="D:/example/jobs")
@server.job("generate-depth")
def generate_depth(context, parameters):
input_path = parameters["input"]
output_path = context.directory / "depth.npz"
context.update(0.1, "Loading image")
context.check_cancelled()
run_model(input_path, output_path)
context.update(
1.0,
"Depth is ready",
state="succeeded",
output=output_path,
)
```
处理函数签名固定为 `handler(context, parameters)`。`parameters` 必须可由 JSON 表示。客户端提交的 `JobRequest` 不包含 ID;`JobServer` 接受请求时自动生成 opaque Job ID,并创建 `<jobs_directory>/<job_id>/`。处理函数通过 `context.job_id` 与 `context.directory` 获取它们。客户端不创建 Job 目录,也不应把输出目录作为业务参数传入。Job API 只保存内存状态和少量结果元数据,不创建 `status.json`。
`context.update()` 支持以下通用字段:
- `progress`:`0.0–1.0`
- `message`:面向界面的当前步骤
- `state`:通常自动推导,也可显式设置 `queued`、`running`、`succeeded`、`failed`、`cancelling` 或 `cancelled`
- `error`:失败信息
- 其它关键字:作为插件自定义状态字段原样返回
长耗时流程应在推理前后和循环内调用 `context.check_cancelled()`。底层库不可中断时,取消会在它返回后的最近检查点生效。
## Blender 端底层 API
`JobClient` 只依赖 Python 标准库,适合直接放入 Blender 插件:
```python
from blendjob import JobClient, JobRequest, ServiceController
client = JobClient("127.0.0.1", port)
request = JobRequest.create(
"generate-depth",
input="D:/images/source.png",
)
submitted = client.submit_request(request)
job_id = submitted["job_id"]
job_directory = submitted["directory"]
status = client.status(job_id)
if status["state"] == "running":
update_blender_progress(status["progress"], status["message"])
```
专属 Service 进程由 `ServiceController` 管理。插件只提供启动命令、工作目录和日志路径,端口、Instance ID、进程句柄、连接验证以及 `start()`、`ensure()`、`enable()`、`restart()`、`stop()`、`detach()` 都由 Controller 负责:
```python
controller = ServiceController(
lambda port, instance_id: [
str(environment_python),
"server.py",
"--port", str(port),
"--instance-id", instance_id,
"--parent-pid", str(blender_pid),
],
cwd_factory=lambda: storage_root,
log_path_factory=lambda: storage_root / "service.log",
)
connection = controller.ensure()
submitted = connection.client.submit_request(request)
controller.stop()
```
对应 HTTP API:
- `GET /health`:查询服务身份、忙闲状态和插件扩展状态
- `POST /jobs`:提交 Job
- `GET /jobs/{job_id}`:轮询进度与终态
- `DELETE /jobs/{job_id}`:请求取消
- `GET /resources`:查询全部 Resource
- `GET /resources/{resource_name}`:查询一个 Resource
- `POST /resources/{resource_name}/clear`:空闲时清理 Resource
- `POST /shutdown`:关闭当前专属服务
Service 只监听 `127.0.0.1`,专属实例由启动它的 `ServiceController` 保存随机端口与 Instance ID。这个本地专属进程协议不额外使用 Token 鉴权。
`ModalJobOperatorBase` 默认使用 Blender Window Manager 的进度接口。插件若有自己的状态栏,可覆盖 `update_job_ui()` 与 `redraw_job_ui()`;这些属于界面定制,不是接入 Job 所必需的代码。运行态使用通用 `ModalJobState`,只包含 Controller、Job ID、目录、进度和取消状态。插件特有的完成参数可由 `create_job_data()` 返回,并通过 `job.data` 读取,不应加入通用状态类。
## 生命周期建议
每个 Blender 实例为每个插件启动一个专属 Job Server。随机端口与 Instance ID 由 `ServiceController` 生成并只保存在内存中,不写公共发现文件,也不扫描其它本地服务。
启动服务时传入 Blender 的 PID。服务同时监听显式 `/shutdown` 和父进程存活状态,因此正常禁用插件时可立即停止,Blender 异常退出后也能自行退出。这个方案允许同一机器出现多个服务进程,换取明确的所有权和插件隔离。
模型与 Environment 可以继续放在插件自己的共享 Storage Root 中;共享磁盘缓存不代表共享服务进程。若多个 Blender 可能同时写同一个模型目录,插件仍需对下载或更新操作单独加文件锁。
## 在其它插件复用
当前实现位于 `src/snapform/blendjob/`,没有 SnapForm 依赖。复用时复制整个 `blendjob` package,并在插件专属入口中完成以下工作:
1. 创建带 `jobs_directory` 的 `JobServer`
2. 用 `@server.job()` 注册插件自己的处理函数
3. 用 `server.add_resource()` 注册需要跨 Job 复用的状态
4. 用安装环境中的 Uvicorn 运行 `server.create_app(instance_id)`
5. Blender 端用 `ServiceController` 管理启动、停止、重启和连接检查
6. Blender Operator 继承 `ModalJobOperatorBase`,声明 `controller_factory`、`job_type` 与 `job_parameters()`
7. 需要导入产物时覆盖 `on_job_succeeded()`
后续若多个项目稳定复用,可把 `blendjob` 发布为独立 Python package;现阶段保持源码内嵌可以避免为 Blender 扩展增加安装依赖。

View File

@@ -15,16 +15,29 @@ uv run python pack.py
├── src/
│ └── snapform/
│ ├── blender_manifest.toml
│ ├── service.py
│ ├── install_env.py
│ ├── models.py
│ ├── image_depth.py
│ ├── operators.py
│ ├── panel.py
│ ├── preferences.py
│ ├── properties.py
│ ├── client.py
│ └── worker.py
│ ├── blendjob/
│ │ ├── client.py
│ │ ├── operator.py
│ │ ├── service.py
│ │ └── server.py
│ ├── blender/
│ │ ├── depth_plane.py
│ │ └── surface.py
│ └── ai_service/
│ ├── server.py
│ ├── model_manager.py
│ ├── jobs.py
│ ├── ben2_onnx.py
│ ├── da3_onnx.py
│ ├── moge_onnx.py
│ └── onnx_runtime.py
└── tests/
```

View File

@@ -58,7 +58,7 @@
(Image Empty 右键菜单)使用的推理设备,默认取本机最快的 DirectML/CoreML provider,否则 CPU,且只列出
本机支持的设备。
4. 在 `AI Service` 点击 `Install Environment`。首次安装会下载 Python、ONNX Runtime、FastAPI、Uvicorn;
uv 已内置在扩展中,DA3、BEN2 与 MoGe 不安装源码包,
uv 由 Bootstrap 下载并校验,DA3、BEN2 与 MoGe 不安装源码包,
并在 Environment 安装后自动下载 `MoGe-2 ViT-S Normal` 和 FP16 `BEN2 Base` 推荐模型。Environment 安装
首次安装依次显示 `uv`、`ONNX Environment`、`MoGe-2 ViT-S Normal` 与 `BEN2 Base`;每个阶段
都独立从 0 走到 100%,进入下一阶段时进度条归零。uv 已存在时省略其阶段并重新编号。
@@ -71,8 +71,8 @@
Environment 安装阶段与 `Models` 面板单独下载使用相同的模型名称格式,不会改写为通用的 `Downloading packages`。
Environment 安装后即使模型下载中断也不影响 Service 使用,可稍后在 `Models` 面板单独补下。
安装不做启动网络预检:依赖安装默认依次尝试清华、阿里镜像并以 pypi.org 兜底;模型下载由
worker 自行探测回退到 OmooLab 镜像,切换结果写入 service.json 的 models_from_r2 字段,
本次周期内后续模型直接走 R2;需要手动指定依赖镜像时可通过
Downloader 自行探测回退到 OmooLab 镜像,切换结果写入 `runtime.json` 的 `models_from_r2` 字段,
后续下载直接走 R2;需要手动指定依赖镜像时可通过
`UV_INDEX_URL` 环境变量覆盖。
5. 在 3D View 按 `N`,打开 `SnapForm` 页签。
6. 如需其他模型,展开 `Models` panel,找到模型并点击该行的 `Download`。

View File

@@ -7,4 +7,5 @@
- [Surface](surface.md):透视网格、Geometry Nodes 后处理与材质法线
- [输出格式](outputs.md):Debug 输出类型、参数和任务产物
- [AI Service](service.md):Environment、模型缓存、任务目录与运行机制
- [BlendJob 开发者指南](blendjob.md):可复用 Job Server、Blender Client 与装饰器注册 API
- [开发与许可证](development.md):开发验证、打包、上游版本与许可证

View File

@@ -2,7 +2,9 @@
## 运行架构
SnapForm 在独立的 Python 3.12 Environment 中运行常驻 HTTP Service,Blender 只通过标准库 `urllib.request` 提交任务,不把 AI 运行库载入 Blender Python。Service 只监听 `127.0.0.1`,请求使用随机 Token;同一 Storage Root 由 `service.lock` 保证只有一个实例。
SnapForm 在独立的 Python 3.12 Environment 中运行 HTTP Job Server,Blender 只通过标准库 HTTP Client 提交和轮询任务,不把 AI 运行库载入 Blender Python。Service 只监听 `127.0.0.1`,由 BlendJob `ServiceController` 保存随机端口与 Instance ID;专属本地进程协议不使用额外 Token 鉴权。
每个 Blender 实例启动并持有一个 SnapForm 专属服务,不发现或复用其它 Blender、其它插件启动的本地服务。多个 Blender 可以对应多个服务进程,但模型、Environment 与任务产物仍可共享同一个 Storage Root。
Environment、模型和任务结果默认保存在 `~/.snapform`:
@@ -12,12 +14,30 @@ Environment、模型和任务结果默认保存在 `~/.snapform`:
├── models/
├── jobs/
├── manifest.json
├── service.log
├── service.json # Service 运行时存在
└── service.lock # Service 运行时持有
├── runtime.json # 持久的模型下载源状态
├── environment.log
├── environment-status.json
└── service.log
```
不同 Blender 版本可共享此目录。也可以在 Extension Preferences 的 `Storage Root` 中修改位置。
不同 Blender 版本可共享此目录。也可以在 Extension Preferences 的 `Storage Root` 中修改位置。连接端口与 Instance ID 只保存在启动它们的 Blender 进程内存中。
通用 HTTP 层位于 `blendjob` module。`JobServer` 包装 FastAPI 并提供 Job 与 Resource API,`JobClient` 是无第三方依赖的 Blender 侧访问器。SnapForm 的 `ai_service/server.py` 只创建专属 Server、注册任务型 Job,并把 `ModelManager` 注册为 `models` Resource,不直接使用 FastAPI。完整接入方式见 [BlendJob 开发者指南](blendjob.md)。
SnapForm 公开的 Request 构造函数与 Job Type 一一对应:
- `download_model` → `download-model`
- `remove_background` → `remove-background`
- `extract_frames` → `extract-frames`
- `generate_da3_depth` → `generate-da3-depth`
- `generate_moge2_depth` → `generate-moge2-depth`
- `generate_da3_surface` → `generate-da3-surface`
- `generate_moge2_surface` → `generate-moge2-surface`
- `debug_da3` → `debug-da3`
- `debug_moge2` → `debug-moge2`
- `debug_ben2` → `debug-ben2`
DA3 与 MoGe-2 的生产任务不再合并成带 Backend 参数的通用 Request。每种 Request 只携带自己的有效参数,Debug 任务也与生产任务完全分开。
## ONNX-only Environment
@@ -41,7 +61,9 @@ Windows 当前发行环境提供 DirectML 与 CPU;`Auto` 优先使用 `DmlExec
- DA3 Small、DA3 Base
- BEN2 Base
首次安装自动下载 `MoGe-2 ViT-S Normal` 与 FP16 `BEN2 Base`,其他模型在 `Models` panel 按需下载。模型按 Hugging Face 仓库末段名称保存在 `~/.snapform/models/`,并保留仓库内部结构。声明了文件清单与校验值的模型会直接走 Hugging Face `resolve` 直链流式下载,跳过文件清单与 ETag 预校验;连接源不可达时使用 3 秒 TCP 与 TLS 建连超时,随后回退到 `https://models.omoolab.xyz/<repository-name>/<relative-path>`,并把 `models_from_r2` 写入 service.json,本次 service 周期内后续模型直接走 R2,不再重复等待 Hugging Face。建连成功后不限制模型文件读取时长。R2 文件会校验大小与 SHA-256。未声明清单的仓库仍使用 `huggingface_hub` 快照下载。可用 `HF_ENDPOINT` 指定 Hugging Face 镜像。
首次安装由 Bootstrap 流程自动下载 `MoGe-2 ViT-S Normal` 与 FP16 `BEN2 Base`。Environment 就绪后,`Models` panel 的后续下载提交为 `download-model` Job,与推理共用单任务调度、HTTP 进度和取消协议。模型按声明的目录名保存在 `~/.snapform/models/`,并保留仓库内部结构。声明了文件清单与校验值的模型会直接走 Hugging Face `resolve` 直链流式下载,跳过文件清单与 ETag 预校验;连接源不可达时使用 3 秒 TCP 与 TLS 建连超时,随后回退到 `https://models.omoolab.xyz/<repository-name>/<relative-path>`。镜像选择写入持久的 `runtime.json`,后续下载直接走 R2,避免每次 Service 重启后重复等待 Hugging Face。建连成功后不限制模型文件读取时长。R2 文件会校验大小与 SHA-256。未声明清单的仓库仍使用 `huggingface_hub` 快照下载。可用 `HF_ENDPOINT` 指定 Hugging Face 镜像。
`models` Resource 对外统一表示模型系统,内部区分磁盘模型与内存 Session。`ModelSessionCache` 保留 BEN2,并在 DA3 与 MoGe-2 之间只保留最近使用的一种 Depth Session。`GET /resources/models` 返回当前加载状态;`POST /resources/models/clear` 只允许在没有 Active Job 时执行。
DA3 ONNX 使用模型的 camera decoder 恢复外参与内参;旧版 `Use Ray Pose` 参数只为任务协议兼容保留,不参与 ONNX 推理。MoGe-2 的 metric depth、normal、mask、normalized intrinsics,以及 BEN2 的 Alpha,均在 NumPy 后处理中恢复。
@@ -56,9 +78,15 @@ uv 默认从 Astral 的 GitHub Release 下载,TCP 与 TLS 建连超过 3 秒
## 生命周期
扩展启用后只查找已有 Service;第一次执行 AI 操作或点击 `Start` 时才启动。每个 Blender 客户端每 5 秒发送心跳,正常关闭时注销。最后一个客户端离开后,Service 保留模型缓存 20 秒再退出。
扩展启用后不启动 Service;第一次执行 AI 操作或点击 `Start` 时才为当前 Blender 启动专属实例。正常关闭或禁用扩展时直接停止自己启动的进程;服务也监视 Blender 父进程 PID,父进程异常退出后会自行停止。
Service 全局同时执行一个 AI 任务。BEN2 独立缓存,深度模型只保留最近使用的一个;切换模型时释放旧会话。取消任务不会重启 Service,ONNX Runtime 当前调用返回后在最近检查点结束。
AI、帧提取和 Models panel 下载的 Job 进度保存在服务内存中。Blender 每 0.25 秒轮询 `GET /jobs/{job_id}`,任务目录不写 `status.json`。`JobRequest` 不包含 ID;Job 被接受时由 `JobServer` 自动生成 ID 并创建 `jobs/<opaque-job-id>/`,提交响应同时返回 `job_id` 与 `directory`。路径通过 `JobContext.directory` 传给 handler,SnapForm Service 强制将 Worker 输出写入该目录。
SnapForm 的 Blender 侧访问与全部 Request 构造函数集中在 `client.py`。提交线程、轮询、进度、取消和终态清理由 `blendjob.ModalJobOperatorBase` 直接提供;`operators.py` 的 `SnapFormJobOperator` 只保留 SnapForm 输入校验、Request 选取和完成后的 Blender 结果导入。通用运行态是 `ModalJobState`,SnapForm 特有的导入参数单独放在 `SnapFormJobData`。插件专属 Service 只在 `ai_service/server.py` 注册 Job,完全不直接编写 FastAPI Route。
只有 Environment 安装及其首次默认模型下载属于 Service 启动前的 Bootstrap 流程,使用 Storage Root 下共享的 `environment-status.json` 和 `environment.log` 回传进度,不创建 Job 目录。
Service 全局同时执行一个 Job,包括模型下载。取消任务不会重启 Service;流式模型下载会在分块检查点结束,ONNX Runtime 当前调用则在返回后的最近检查点结束。JobServer 最多保留 100 个已完成 Job 的内存状态,后续提交时清理更早记录。
启动与崩溃信息写入 `~/.snapform/service.log`。

View File

@@ -166,52 +166,21 @@ Image Empty 使用 Packed Image 时,SnapForm 会通过 Blender 写出临时 PN
`DA3 Nested Giant Large 1.1`。Small 和 Base 没有 Gaussian head,无法由
DA3 生成 `gs_ply`。
每个任务在 `~/.snapform/jobs/` 下用独立文件夹包裹。文件夹统一采用
`动作-对象_日期-时间-微秒`,每种任务都有唯一名称,例如
`generate-depth-plane_20260807-153000-123456`。微秒用于避免同一秒启动的任务共用目录。
每个 Service Job 被接受时,BlendJob 在 `~/.snapform/jobs/` 下创建一个独立文件夹。文件夹名是 32 位 opaque Job ID,不包含任务类型或对象名称。执行逻辑从 `JobContext.directory` 获取路径,输出产物直接写入该目录。
Blender 为 Packed Image 或序列帧生成的输入副本也归档到对应 job 根目录:单图命名为
`input.png`,序列帧命名为 `frame.0001.*`。它们以前暂存在 `jobs/inputs/`,现在不再创建这个
公共中转文件夹。普通磁盘文件和视频仍直接读取原文件,不额外复制。
Blender 为 Packed Image 或序列帧生成的输入副本先保存在系统临时目录,Service 读取完成后由 Blender 清理,不归档到 Job 目录。普通磁盘文件和视频仍直接读取原文件。Service Job 日志统一写入 `service.log`,进度只保存在 Service 内存中。Environment Bootstrap 使用 Storage Root 下的 `environment.log` 与 `environment-status.json`,不创建 Job 目录。
日志、运行中的状态文件、归档输入与输出产物均直接放在 job 根目录。各类任务名称为:
- `install-environment`
- `download-model`
- `export-reconstruction`
- `generate-depth-plane`
- `generate-image-mesh`
- `remove-background`
- `remove-image-background`
- `generate-original-depth-plane`
与几何输出相关的 Job Type 为 `generate-da3-depth`、`generate-moge2-depth`、`generate-da3-surface`、`generate-moge2-surface`、`debug-da3` 与 `debug-moge2`。每种 Job 只接收对应模型与输出所需的参数。
DA3 ONNX exporter 产生的 `depth_vis/`、`exports/mini_npz/` 会在完成后自动平铺,
不会保留只含一层产物的子目录:
```text
export-reconstruction_20260807-153000-123456/
├── job.log
├── status.json
└── scene.glb
<opaque-job-id>/
├── scene.glb、*.ply、*.jpg 或 results.npz
└── 其它对应 Debug 格式的产物
export-reconstruction_20260807-153000-123456/
├── job.log
├── status.json
└── *.ply
export-reconstruction_20260807-153000-123456/
├── job.log
├── status.json
└── *.jpg
export-reconstruction_20260807-153000-123456/
├── job.log
├── status.json
└── results.npz
remove-background_20260807-153000-123456/
├── job.log
├── status.json
<opaque-job-id>/
├── foreground.png 单图结果
├── foreground.0001.png… 视频/序列帧结果
├── frame.0001.jpg… 视频/序列帧的源帧副本

View File

@@ -1,7 +1,9 @@
import bpy
from . import client
from .image_depth import (
from .blender import depth_plane as image_depth
from .blender import surface as image_mesh
from .blender.depth_plane import (
ApplyRemovedBackground,
BuildDepthPlane,
BuildOriginalDepthPlane,
@@ -11,11 +13,11 @@ from .image_depth import (
SnapFormImageMenu,
draw_image_context_menu,
)
from .image_mesh import BuildImageMesh, ConvertImageToMesh
from .blender.surface import BuildImageMesh, ConvertImageToMesh
from .operators import (
CancelJob,
ControlService,
RunJob,
JOB_OPERATOR_CLASSES,
cancel_active_job,
close_active_job,
)
@@ -35,7 +37,7 @@ from .status_bar import draw_status_bar
CLASSES = (
SnapFormPreferences,
SnapFormSettings,
RunJob,
*JOB_OPERATOR_CLASSES,
CancelJob,
ControlService,
SnapFormImageMenu,

View File

@@ -0,0 +1 @@
"""SnapForm AI Service implementation without Blender dependencies."""

View File

@@ -84,7 +84,7 @@ def check_cancelled(cancel_check):
def write_status(
path,
target,
progress,
message,
error=None,
@@ -111,7 +111,19 @@ def write_status(
else:
state = "running"
payload["state"] = state
Path(path).write_text(
if callable(target):
target(
progress,
message,
error=error,
state=state,
stage=stage,
stages=stages,
stage_label=stage_label,
eta_seconds=eta_seconds,
)
return
Path(target).write_text(
json.dumps(payload, ensure_ascii=False),
encoding="utf-8",
)
@@ -991,15 +1003,15 @@ def progress_range(args, staged):
return 0.05, 0.90
def _discovery_path(model_dir):
"""storage_root/service.json,从 storage_root/models/<name> 反推 storage_root。"""
return Path(model_dir).parent.parent / "service.json"
def _runtime_state_path(model_dir):
"""Return the storage-level download routing state path."""
return Path(model_dir).parent.parent / "runtime.json"
def _models_from_r2(model_dir):
try:
payload = json.loads(
_discovery_path(model_dir).read_text(encoding="utf-8")
_runtime_state_path(model_dir).read_text(encoding="utf-8")
)
except (OSError, ValueError, json.JSONDecodeError):
return False
@@ -1007,8 +1019,8 @@ def _models_from_r2(model_dir):
def _mark_models_from_r2(model_dir):
"""在 service.json 上记录 models_from_r2,保留文件里已有的其它字段。"""
path = _discovery_path(model_dir)
"""记录当前运行周期的模型镜像选择。"""
path = _runtime_state_path(model_dir)
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError, json.JSONDecodeError):
@@ -1082,6 +1094,9 @@ def _download_declared_or_fail(args, model_dir, label, start, span, stage_kwargs
getattr(args, "required_pattern", None),
)
except Exception as error:
cancel_check = getattr(args, "cancel_check", None)
if cancel_check is not None:
cancel_check()
failures.append((source, error))
continue
# 非首个源成功意味着 hf 已失败,记住本次 service 周期后续直接走 r2。
@@ -1139,6 +1154,9 @@ def _download_snapshot_or_fail(args, model_dir, label, start, span, stage_kwargs
getattr(args, "required_pattern", None),
)
except Exception as error:
cancel_check = getattr(args, "cancel_check", None)
if cancel_check is not None:
cancel_check()
failures.append((endpoint, error))
continue
write_status(
@@ -1186,6 +1204,7 @@ def _download_file_from_url(
expected_checksum,
progress=None,
retries=2,
cancel_check=None,
):
"""Download and verify one model file atomically from a direct URL."""
request = Request(url, headers={"User-Agent": "SnapForm"})
@@ -1199,6 +1218,8 @@ def _download_file_from_url(
with opener(request) as response:
with open(temporary, "wb") as output:
while True:
if cancel_check is not None:
cancel_check()
chunk = response.read(DOWNLOAD_CHUNK_SIZE)
if not chunk:
break
@@ -1217,6 +1238,8 @@ def _download_file_from_url(
temporary.replace(destination)
return
except Exception as error:
if cancel_check is not None:
cancel_check()
last_error = error
try:
temporary.unlink()
@@ -1282,6 +1305,7 @@ def _download_declared_files(
checksum,
progress=on_chunk,
retries=retries,
cancel_check=getattr(args, "cancel_check", None),
)

View File

@@ -0,0 +1,153 @@
import gc
import time
from pathlib import Path
from types import SimpleNamespace
import jobs
import models as model_catalog
class ModelSessionCache:
"""Cache ONNX sessions shared by jobs in one service process."""
def __init__(self):
self.ben2_model = None
self.ben2_key = None
self.da3_model = None
self.da3_key = None
self.moge_model = None
self.moge_key = None
def get_ben2(self, model_dir, device):
requested_device = getattr(device, "type", device)
key = (str(Path(model_dir).resolve()), requested_device, "onnx")
if self.ben2_model is not None and self.ben2_key == key:
return self.ben2_model, 0.0
self.release_ben2()
return self._load_ben2(model_dir, requested_device, key)
def _load_ben2(self, model_dir, requested_device, key):
from ben2_onnx import create_session
started = time.perf_counter()
model = create_session(model_dir, requested_device)
self.ben2_model = model
self.ben2_key = key
elapsed_ms = (time.perf_counter() - started) * 1000.0
return model, elapsed_ms
def get_da3(self, model_dir, device):
requested_device = getattr(device, "type", device)
key = (str(Path(model_dir).resolve()), requested_device, "onnx")
if self.moge_model is not None:
self.release_moge()
if self.da3_model is not None and self.da3_key == key:
return self.da3_model
self.release_da3()
return self._load_da3(model_dir, requested_device, key)
def _load_da3(self, model_dir, requested_device, key):
from da3_onnx import create_session
model = create_session(model_dir, requested_device)
self.da3_model = model
self.da3_key = key
return model
def get_moge_onnx(self, model_dir, requested_device):
from moge_onnx import create_session
key = (str(Path(model_dir).resolve()), requested_device, "onnx")
if self.da3_model is not None:
self.release_da3()
if self.moge_model is not None and self.moge_key == key:
return self.moge_model
self.release_moge()
model = create_session(model_dir, requested_device)
self.moge_model = model
self.moge_key = key
return model
def release_ben2(self):
self.ben2_model = None
self.ben2_key = None
self._release_runtime_cache()
def release_da3(self):
self.da3_model = None
self.da3_key = None
self._release_runtime_cache()
def release_moge(self):
self.moge_model = None
self.moge_key = None
self._release_runtime_cache()
def close(self):
self.ben2_model = None
self.da3_model = None
self.moge_model = None
self.ben2_key = None
self.da3_key = None
self.moge_key = None
self._release_runtime_cache()
def snapshot(self):
return {
"ben2": Path(self.ben2_key[0]).name if self.ben2_key else "",
"da3": Path(self.da3_key[0]).name if self.da3_key else "",
"moge2": Path(self.moge_key[0]).name if self.moge_key else "",
}
def _release_runtime_cache(self):
gc.collect()
class ModelManager:
"""Own downloaded model files and loaded inference sessions."""
def __init__(self, models_directory):
self.models_directory = Path(models_directory)
self.sessions = ModelSessionCache()
def directory(self, model_key):
model = model_catalog.get_downloadable_model(model_key)
return self.models_directory / model.directory_name
def get_ben2(self, model_dir, device):
return self.sessions.get_ben2(model_dir, device)
def get_da3(self, model_dir, device):
return self.sessions.get_da3(model_dir, device)
def get_moge_onnx(self, model_dir, device):
return self.sessions.get_moge_onnx(model_dir, device)
def snapshot(self):
return {"loaded": self.sessions.snapshot()}
def clear(self):
self.sessions.close()
def close(self):
self.sessions.close()
def download(self, context, model_key):
model = model_catalog.get_downloadable_model(model_key)
args = SimpleNamespace(
repository=model.repository,
model_dir=str(self.directory(model_key)),
status=context.update,
cancel_check=context.check_cancelled,
label=model.label,
allow_pattern=list(model.download_patterns),
required_pattern=list(model.ready_patterns),
r2_directory=model.r2_directory,
r2_file=[
f"{filename}|{size}|{checksum}"
for filename, size, checksum in model.r2_files
],
)
context.check_cancelled()
jobs.download_model(args)
context.check_cancelled()

View File

@@ -0,0 +1,145 @@
import argparse
import sys
import threading
from pathlib import Path
from types import SimpleNamespace
SCRIPT_DIRECTORY = Path(__file__).resolve().parent
ADDON_DIRECTORY = SCRIPT_DIRECTORY.parent
for import_directory in (SCRIPT_DIRECTORY, ADDON_DIRECTORY):
if str(import_directory) not in sys.path:
sys.path.insert(0, str(import_directory))
import jobs as worker
from model_manager import ModelManager, ModelSessionCache
from blendjob.server import JobCancelled, JobServer, watch_parent
def create_server(models_directory=None, jobs_directory=None):
"""Create the SnapForm-specific server and register its job types."""
server = JobServer("SnapForm Job Server", jobs_directory=jobs_directory)
models = server.add_resource(
"models",
ModelManager(models_directory or Path.cwd() / "models"),
)
def run_worker(context, parameters, function, *, uses_models=True):
parameters = dict(parameters)
parameters["output_dir"] = str(context.directory)
models = context.resource("models") if uses_models else None
model_key = parameters.pop("model", "")
if model_key:
parameters["model_dir"] = str(models.directory(model_key))
background_model = parameters.pop("remove_background_model", "")
if background_model:
parameters["remove_background_model_dir"] = str(
models.directory(background_model)
)
args = SimpleNamespace(**parameters)
args.status = context.update
options = {"cancel_check": context.is_cancelled}
if uses_models:
options["model_cache"] = models
try:
function(args, **options)
except worker.TaskCancelled as error:
raise JobCancelled(str(error))
@server.job("remove-background")
def remove_background(context, parameters):
run_worker(context, parameters, worker.remove_background)
@server.job("debug-ben2")
def debug_ben2(context, parameters):
run_worker(context, parameters, worker.remove_background)
@server.job("extract-frames")
def extract_frames(context, parameters):
run_worker(
context,
parameters,
worker.extract_frames,
uses_models=False,
)
@server.job("generate-da3-depth")
def generate_da3_depth(context, parameters):
run_worker(context, parameters, worker.reconstruct)
@server.job("generate-da3-surface")
def generate_da3_surface(context, parameters):
run_worker(context, parameters, worker.reconstruct)
@server.job("debug-da3")
def debug_da3(context, parameters):
run_worker(context, parameters, worker.reconstruct)
@server.job("generate-moge2-depth")
def generate_moge2_depth(context, parameters):
run_worker(context, parameters, worker.run_moge)
@server.job("generate-moge2-surface")
def generate_moge2_surface(context, parameters):
parameters = {**parameters, "surface_mesh": True}
run_worker(context, parameters, worker.run_moge)
@server.job("debug-moge2")
def debug_moge2(context, parameters):
parameters = dict(parameters)
parameters["debug_export_format"] = parameters.pop("export_format")
run_worker(context, parameters, worker.run_moge)
@server.job("download-model")
def download_model(context, parameters):
context.resource("models").download(context, parameters["model"])
return server
def create_app(instance_id="test", server=None):
"""Create the dedicated SnapForm FastAPI app."""
server = server or create_server()
return server.create_app(instance_id)
def parse_args():
parser = argparse.ArgumentParser()
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, required=True)
parser.add_argument("--instance-id", required=True)
parser.add_argument("--parent-pid", type=int, required=True)
return parser.parse_args()
def main():
import uvicorn
args = parse_args()
job_server = create_server()
app = create_app(args.instance_id, job_server)
config = uvicorn.Config(
app,
host=args.host,
port=args.port,
access_log=False,
log_level="warning",
)
http_server = uvicorn.Server(config)
def stop_http_server():
job_server.shutdown_event.wait()
http_server.should_exit = True
threading.Thread(target=stop_http_server, daemon=True).start()
threading.Thread(
target=watch_parent,
args=(args.parent_pid, job_server.shutdown_event),
name="SnapFormParentMonitor",
daemon=True,
).start()
http_server.run()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1 @@
"""Blender-process geometry construction and import logic."""

View File

@@ -53,7 +53,7 @@ VIDEO_NAME_SUFFIXES = (
def image_depth_model_items(owner, context):
from .models import depth_plane_model_enum_items
from ..models import depth_plane_model_enum_items
return depth_plane_model_enum_items(owner, context)
@@ -228,7 +228,7 @@ def draw_process_resolution(layout, owner, image):
def save_packed_image(image, scene):
from .client import temporary_input_path
from ..client import temporary_input_path
output_path = temporary_input_path()
image_settings = scene.render.image_settings
@@ -286,7 +286,7 @@ def prepare_image_input(image, scene):
return source_path, False
sequence_frames = collect_sequence_frames(image)
if sequence_frames:
from .client import temporary_input_directory
from ..client import temporary_input_directory
temporary_directory = Path(temporary_input_directory())
try:
@@ -1487,7 +1487,7 @@ def set_displacement_only(material):
def node_asset_path():
return Path(__file__).with_name("assets") / NODE_ASSET_NAME
return Path(__file__).parent.parent / "assets" / NODE_ASSET_NAME
def load_depth_plane_node_group(group_name=DEPTH_PLANE_NODE_GROUP_NAME):
@@ -2227,9 +2227,9 @@ class RemoveImageBackground(bpy.types.Operator):
@classmethod
def poll(cls, context):
from .operators import active_job
from .client import service_busy, model_ready
from .models import BEN2_MODEL
from ..operators import active_job
from ..client import service_busy, model_ready
from ..models import BEN2_MODEL
return (
is_image_empty(context.object)
@@ -2239,8 +2239,6 @@ class RemoveImageBackground(bpy.types.Operator):
)
def execute(self, context):
from .operators import IMAGE_BACKGROUND_JOB
source_object = context.object
total_frames = video_total_frames(source_object.data)
if total_frames > 1:
@@ -2256,8 +2254,7 @@ class RemoveImageBackground(bpy.types.Operator):
self.report({"ERROR"}, str(error))
return {"CANCELLED"}
result = bpy.ops.snapform.run_job(
job_type=IMAGE_BACKGROUND_JOB,
result = bpy.ops.snapform.process_image_background(
snapform_input_path=str(source_path),
snapform_source_object_name=source_object.name,
snapform_delete_input=delete_input,
@@ -2312,8 +2309,8 @@ class ConvertImageToDepthPlane(bpy.types.Operator):
@classmethod
def poll(cls, context):
from .operators import active_job
from .client import service_busy
from ..operators import active_job
from ..client import service_busy
return (
is_image_empty(context.object)
@@ -2337,13 +2334,6 @@ class ConvertImageToDepthPlane(bpy.types.Operator):
layout.prop(self, "snapform_keep_original")
def execute(self, context):
from .operators import (
IMAGE_DEPTH_JOB,
MOGE2_DEPTH_JOB,
ORIGINAL_BACKGROUND_JOB,
ORIGINAL_FRAMES_JOB,
)
source_object = context.object
if video_total_frames(source_object.data) > 1:
context.scene.snapform_settings.video_frames = (
@@ -2358,12 +2348,12 @@ class ConvertImageToDepthPlane(bpy.types.Operator):
except RuntimeError as error:
self.report({"ERROR"}, str(error))
return {"CANCELLED"}
result = bpy.ops.snapform.run_job(
job_type=(
ORIGINAL_BACKGROUND_JOB
if self.snapform_remove_background
else ORIGINAL_FRAMES_JOB
),
run_original = (
bpy.ops.snapform.generate_original_background
if self.snapform_remove_background
else bpy.ops.snapform.extract_original_frames
)
result = run_original(
snapform_input_path=str(source_path),
snapform_source_object_name=source_object.name,
snapform_delete_source=not self.snapform_keep_original,
@@ -2385,8 +2375,7 @@ class ConvertImageToDepthPlane(bpy.types.Operator):
except RuntimeError as error:
self.report({"ERROR"}, str(error))
return {"CANCELLED"}
result = bpy.ops.snapform.run_job(
job_type=MOGE2_DEPTH_JOB,
result = bpy.ops.snapform.generate_moge2_depth(
snapform_input_path=str(source_path),
snapform_source_object_name=source_object.name,
snapform_moge_resolution_level=(
@@ -2422,8 +2411,7 @@ class ConvertImageToDepthPlane(bpy.types.Operator):
self.report({"ERROR"}, str(error))
return {"CANCELLED"}
result = bpy.ops.snapform.run_job(
job_type=IMAGE_DEPTH_JOB,
result = bpy.ops.snapform.generate_da3_depth(
snapform_input_path=str(source_path),
snapform_source_object_name=source_object.name,
snapform_depth_data_name=depth_data_name(source_object),
@@ -2447,17 +2435,16 @@ class SnapFormImageMenu(bpy.types.Menu):
bl_label = "SnapForm"
def draw(self, _context):
from .client import env_installed
from ..client import env_installed
layout = self.layout
layout.operator_context = "INVOKE_DEFAULT"
if not env_installed():
operator = layout.operator(
"snapform.run_job",
layout.operator(
"snapform.install_environment",
text="Install Environment",
icon="IMPORT",
)
operator.job_type = "INSTALL_ENV"
return
layout.operator(
"snapform.convert_image_to_depth_plane",

View File

@@ -2,7 +2,7 @@ from pathlib import Path
import bpy
from .image_depth import (
from .depth_plane import (
MOGE_SURFACE_MATERIAL_NODE_GROUP_NAME,
create_material,
draw_process_resolution,
@@ -37,7 +37,7 @@ NODE_ASSET_NAME = "O_SnapForm.blend"
def image_surface_model_items(owner, context):
from .models import surface_model_enum_items
from ..models import surface_model_enum_items
return surface_model_enum_items(owner, context)
@@ -329,7 +329,7 @@ def depth_discontinuity_ratio(depth):
def node_asset_path():
return Path(__file__).with_name("assets") / NODE_ASSET_NAME
return Path(__file__).parent.parent / "assets" / NODE_ASSET_NAME
def load_geometry_node_group(group_name=NODE_GROUP_NAME):
@@ -502,7 +502,7 @@ def build_image_mesh(
depth_scale = material_depth_scale(mesh_object, camera_parameters)
normal_image = None
if moge2 and attributes.get("normal_map", np.empty(0)).size:
from .image_depth import create_moge_normal_image
from .depth_plane import create_moge_normal_image
normal_map = attributes["normal_map"].copy()
normal_map[..., 0] /= camera_parameters["scale_x"]
@@ -651,9 +651,9 @@ class ConvertImageToMesh(bpy.types.Operator):
@classmethod
def poll(cls, context):
from .models import MODELS, MOGE2_MODELS
from .operators import active_job
from .client import service_busy, model_ready
from ..models import MODELS, MOGE2_MODELS
from ..operators import active_job
from ..client import service_busy, model_ready
return (
is_image_empty(context.object)
@@ -683,8 +683,6 @@ class ConvertImageToMesh(bpy.types.Operator):
layout.prop(self, "snapform_keep_original")
def execute(self, context):
from .operators import IMAGE_MESH_JOB, MOGE2_MESH_JOB
source_object = context.object
if video_total_frames(source_object.data) != 1:
self.report({"ERROR"}, "Convert to Surface currently supports still images")
@@ -703,24 +701,29 @@ class ConvertImageToMesh(bpy.types.Operator):
except (RuntimeError, ValueError) as error:
self.report({"ERROR"}, str(error))
return {"CANCELLED"}
result = bpy.ops.snapform.run_job(
job_type=(
MOGE2_MESH_JOB
if self.snapform_model.startswith("MOGE2_")
else IMAGE_MESH_JOB
),
snapform_input_path=str(source_path),
snapform_source_object_name=source_object.name,
snapform_model_key=self.snapform_model,
snapform_use_ray_pose=self.snapform_use_ray_pose,
snapform_process_res=process_res,
snapform_moge_resolution_level=self.snapform_moge_resolution_level,
snapform_delete_source=not self.snapform_keep_original,
snapform_delete_input=delete_input,
snapform_mesh_create_camera=self.snapform_create_camera,
snapform_unshaded=self.snapform_unshaded,
snapform_remove_background=self.snapform_remove_background,
)
common = {
"snapform_input_path": str(source_path),
"snapform_source_object_name": source_object.name,
"snapform_model_key": self.snapform_model,
"snapform_delete_source": not self.snapform_keep_original,
"snapform_delete_input": delete_input,
"snapform_mesh_create_camera": self.snapform_create_camera,
"snapform_unshaded": self.snapform_unshaded,
"snapform_remove_background": self.snapform_remove_background,
}
if self.snapform_model.startswith("MOGE2_"):
result = bpy.ops.snapform.generate_moge2_surface(
**common,
snapform_moge_resolution_level=(
self.snapform_moge_resolution_level
),
)
else:
result = bpy.ops.snapform.generate_da3_surface(
**common,
snapform_use_ray_pose=self.snapform_use_ray_pose,
snapform_process_res=process_res,
)
if "CANCELLED" in result:
if delete_input:
remove_temporary_input(source_path)

View File

@@ -0,0 +1,37 @@
"""Reusable local HTTP job service for Blender add-ons."""
from .client import JobClient, JobRequest
from .server import JobCancelled, JobContext, JobServer
from .service import ServiceConnection, ServiceController
try:
from .operator import (
ModalJobOperatorBase,
ModalJobState,
active_job,
cancel_active_job,
close_active_job,
)
except ModuleNotFoundError as error:
if error.name != "bpy":
raise
__all__ = (
"JobCancelled",
"JobClient",
"JobContext",
"JobRequest",
"JobServer",
"ServiceConnection",
"ServiceController",
)
if "ModalJobOperatorBase" in globals():
__all__ += (
"ModalJobOperatorBase",
"ModalJobState",
"active_job",
"cancel_active_job",
"close_active_job",
)

View File

@@ -0,0 +1,99 @@
import json
from dataclasses import dataclass
from urllib.error import HTTPError, URLError
from urllib.request import ProxyHandler, Request, build_opener
class JobClient:
"""Dependency-free HTTP client for a local :class:`JobServer`."""
def __init__(self, host, port, *, timeout=2.0):
self.base_url = f"http://{host}:{int(port)}"
self.timeout = timeout
self.opener = build_opener(ProxyHandler({}))
def request(self, method, path, payload=None, timeout=None):
data = None
headers = {}
if payload is not None:
data = json.dumps(payload).encode("utf-8")
headers["Content-Type"] = "application/json"
request = Request(
f"{self.base_url}{path}",
data=data,
headers=headers,
method=method,
)
try:
with self.opener.open(
request,
timeout=self.timeout if timeout is None else timeout,
) as response:
return json.loads(response.read().decode("utf-8"))
except HTTPError as error:
try:
detail = json.loads(error.read().decode("utf-8")).get("detail")
except (json.JSONDecodeError, UnicodeDecodeError):
detail = None
raise RuntimeError(detail or f"Job Server returned HTTP {error.code}")
except (OSError, URLError) as error:
raise RuntimeError(f"Unable to reach Job Server: {error}")
def health(self):
return self.request("GET", "/health")
def submit(self, job_type, **parameters):
return self.request(
"POST",
"/jobs",
{
"job_type": job_type,
"parameters": parameters,
},
)
def submit_request(self, request):
return self.request("POST", "/jobs", request.payload)
def status(self, job_id):
return self.request("GET", f"/jobs/{job_id}")
def cancel(self, job_id):
return self.request("DELETE", f"/jobs/{job_id}")
def resources(self):
return self.request("GET", "/resources")
def resource(self, name):
return self.request("GET", f"/resources/{name}")
def clear_resource(self, name):
return self.request("POST", f"/resources/{name}/clear")
def shutdown(self):
return self.request("POST", "/shutdown")
@dataclass(frozen=True, init=False)
class JobRequest:
"""Serializable request created before a Job is submitted."""
job_type: str
parameters: dict
def __init__(self, job_type, parameters=None):
if isinstance(job_type, dict):
payload = job_type
job_type = payload.get("job_type", "")
parameters = payload.get("parameters", {})
object.__setattr__(self, "job_type", str(job_type))
object.__setattr__(self, "parameters", dict(parameters or {}))
@classmethod
def create(cls, job_type, **parameters):
return cls(job_type, parameters)
@property
def payload(self):
return {
"job_type": self.job_type,
"parameters": self.parameters,
}

View File

@@ -0,0 +1,267 @@
import threading
from dataclasses import dataclass
from pathlib import Path
import bpy
from .client import JobRequest
@dataclass
class ModalJobState:
"""Runtime state shared by all Blender modal Job operators."""
controller: object
data: object = None
cleanup: object = None
job_id: str = ""
directory: Path | None = None
started: bool = False
cancelled: bool = False
start_error: str = ""
start_thread: threading.Thread | None = None
progress_stage: int | None = None
progress: float = 0.0
def __getattr__(self, name):
if name == "result_directory":
return self.directory
if self.data is None:
raise AttributeError(name)
return getattr(self.data, name)
_active_job = None
def active_job():
return _active_job
def cancel_active_job():
job = active_job()
if job is None:
return
job.cancelled = True
if not job.started or not job.job_id:
return
try:
job.controller.cancel(job.job_id)
except RuntimeError:
pass
def close_active_job():
global _active_job
if _active_job is not None:
if _active_job.job_id:
_active_job.controller.mark_job_complete(_active_job.job_id)
if _active_job.cleanup is not None:
_active_job.cleanup(_active_job.data)
_active_job.cleanup = None
_active_job = None
class ModalJobOperatorBase(bpy.types.Operator):
"""Ready-to-use Blender lifecycle for one HTTP Job request."""
bl_options = {"INTERNAL"}
poll_interval = 0.25
starting_message = "Starting task"
controller_factory = None
job_type = ""
def get_service_controller(self):
"""Return the ServiceController used by this add-on."""
if self.controller_factory is None:
raise RuntimeError("Set controller_factory on the Job operator")
return self.controller_factory()
def create_job_request(self, context):
"""Create the JobRequest for this operator invocation."""
if not self.job_type:
raise RuntimeError("Set job_type on the Job operator")
return JobRequest(self.job_type, self.job_parameters(context))
def job_parameters(self, _context):
"""Return JSON-compatible business parameters for this invocation."""
return {}
def create_job_data(self, _context):
"""Return optional plug-in data retained until completion."""
return None
def execute(self, context):
global _active_job
if active_job() is not None:
self.report({"WARNING"}, "Another task is running")
return {"CANCELLED"}
try:
request = self.create_job_request(context)
if not isinstance(request, JobRequest):
raise TypeError("create_job_request() must return JobRequest")
controller = self.get_service_controller()
data = self.create_job_data(context)
except (OSError, RuntimeError, TypeError, ValueError) as error:
self.report({"ERROR"}, str(error))
return {"CANCELLED"}
job = ModalJobState(
controller=controller,
data=data,
cleanup=self.cleanup_job_data,
)
_active_job = job
if hasattr(context.window_manager, "progress_begin"):
context.window_manager.progress_begin(0, 100)
self.update_job_ui(context, 0.0, self.get_starting_message(context))
self._timer = context.window_manager.event_timer_add(
self.poll_interval,
window=context.window,
)
context.window_manager.modal_handler_add(self)
self.redraw_job_ui(context, force=True)
job.start_thread = threading.Thread(
target=self._submit_job,
args=(job, request),
name="BlendJobSubmit",
daemon=True,
)
job.start_thread.start()
return {"RUNNING_MODAL"}
def _submit_job(self, job, request):
if job.cancelled:
job.started = True
return
try:
submitted = job.controller.submit(request)
job.job_id = submitted["job_id"]
job.directory = Path(submitted["directory"])
job.started = True
if job.cancelled:
job.controller.cancel(job.job_id)
except (KeyError, OSError, RuntimeError, TypeError) as error:
job.start_error = str(error)
def modal(self, context, event):
if event.type == "ESC":
cancel_active_job()
if event.type != "TIMER":
return {"PASS_THROUGH"}
job = active_job()
if job is None:
self.remove_job_timer(context)
return {"CANCELLED"}
if job.start_error:
return self._finish_start_error(context, job)
if not job.started:
return {"RUNNING_MODAL"}
try:
status = job.controller.status(job.job_id)
except RuntimeError as error:
return self._finish_failure(context, job, str(error))
self.update_from_job_status(context, job, status)
state = status.get("state")
if state == "succeeded":
return self._finish_success(context, job, status)
if state in {"failed", "cancelled"}:
return self._finish_terminal(context, job, status)
return {"RUNNING_MODAL"}
def _finish_start_error(self, context, job):
self.report({"ERROR"}, f"Unable to start task: {job.start_error}")
return self._close(context, job, "Task failed to start", cancelled=True)
def _finish_terminal(self, context, job, status):
if job.cancelled or status.get("state") == "cancelled":
return self._close(context, job, "Task cancelled", cancelled=True)
return self._finish_failure(
context,
job,
status.get("error") or "Service task failed",
)
def _finish_failure(self, context, job, error):
self.report({"ERROR"}, str(error))
return self._close(context, job, "Task failed", cancelled=True)
def _finish_success(self, context, job, status):
try:
message = self.on_job_succeeded(context, job, status)
except (OSError, RuntimeError, ValueError) as error:
return self._finish_failure(context, job, error)
return self._close(
context,
job,
message or status.get("message") or "Task complete",
cancelled=False,
)
def _close(self, context, job, message, *, cancelled):
self.remove_job_timer(context)
if hasattr(context.window_manager, "progress_end"):
context.window_manager.progress_end()
close_active_job()
self.update_job_ui(context, 0.0 if cancelled else 1.0, message)
self.redraw_job_ui(context, force=True)
return {"CANCELLED"} if cancelled else {"FINISHED"}
def get_starting_message(self, _context):
return self.starting_message
def update_from_job_status(self, context, job, status):
reported = min(max(float(status.get("progress", job.progress)), 0.0), 1.0)
stage = status.get("stage")
if stage is not None and stage != job.progress_stage:
job.progress_stage = stage
job.progress = reported
else:
job.progress = max(job.progress, reported)
message = self.format_job_status(status, self.starting_message)
self.update_job_ui(context, job.progress, message)
self.redraw_job_ui(context, force=True)
def update_job_ui(self, context, progress, _message):
"""Update Blender's standard progress indicator by default."""
if hasattr(context.window_manager, "progress_update"):
context.window_manager.progress_update(int(progress * 100))
def on_job_succeeded(self, _context, _job, status):
"""Import results if needed and optionally return a final message."""
return status.get("message")
def cleanup_job_data(self, _data):
pass
def redraw_job_ui(self, _context, force=False):
del force
def remove_job_timer(self, context):
timer = getattr(self, "_timer", None)
if timer is None:
return
context.window_manager.event_timer_remove(timer)
self._timer = None
def format_job_status(self, status, fallback):
message = status.get("message")
stage = status.get("stage")
stages = status.get("stages")
stage_label = status.get("stage_label")
if stage and stages:
prefix = f"{stage}/{stages}"
normalized = str(message or "").strip()
if normalized and normalized.lower() != str(stage_label or "").lower():
return f"{prefix} {normalized}"
return f"{prefix} {stage_label}" if stage_label else prefix
return str(message or stage_label or fallback).strip()
def cancel(self, context):
cancel_active_job()
self.remove_job_timer(context)
self.redraw_job_ui(context, force=True)

View File

@@ -0,0 +1,350 @@
import os
import threading
import time
import traceback
import uuid
from contextlib import asynccontextmanager
from pathlib import Path
TERMINAL_STATES = frozenset({"succeeded", "failed", "cancelled"})
class JobCancelled(RuntimeError):
"""Raised by a handler when its job has been cancelled."""
class JobContext:
"""Mutable state and cancellation API passed to a registered job handler."""
def __init__(self, job_id, job_type, parameters, resources, directory):
self.job_id = job_id
self.job_type = job_type
self.parameters = parameters
self.directory = Path(directory)
self._resources = resources
self._lock = threading.Lock()
self._cancel_requested = False
self._status = {
"job_id": job_id,
"job_type": job_type,
"state": "queued",
"progress": 0.0,
"message": "Task queued",
"directory": str(self.directory),
}
def update(self, progress, message, error=None, state=None, **details):
"""Publish a progress snapshot returned by ``GET /jobs/{job_id}``."""
if state is None:
if error:
state = "failed"
elif progress >= 1.0:
state = "succeeded"
else:
state = "running"
snapshot = {
"job_id": self.job_id,
"job_type": self.job_type,
"state": state,
"progress": min(max(float(progress), 0.0), 1.0),
"message": str(message),
"directory": str(self.directory),
**{key: value for key, value in details.items() if value is not None},
}
if error:
snapshot["error"] = str(error)
with self._lock:
self._status = snapshot
def snapshot(self):
with self._lock:
return dict(self._status)
def request_cancel(self):
with self._lock:
if self._status["state"] in TERMINAL_STATES:
return False
self._cancel_requested = True
self._status = {
**self._status,
"state": "cancelling",
"message": "Cancelling task",
}
return True
def is_cancelled(self):
with self._lock:
return self._cancel_requested
def check_cancelled(self):
if self.is_cancelled():
raise JobCancelled("Task cancelled")
def resource(self, name):
"""Return a shared resource registered on the owning JobServer."""
try:
return self._resources[name]
except KeyError:
raise KeyError(f"Unknown service resource: {name}") from None
class JobServer:
"""Small FastAPI wrapper with decorator-based job registration."""
def __init__(self, name, *, jobs_directory=None, max_job_history=100):
self.name = name
self.max_job_history = max(int(max_job_history), 0)
self.handlers = {}
self.resources = {}
self.jobs = {}
self.active_job_id = None
self.lock = threading.Lock()
self.shutdown_event = threading.Event()
self.started_at = time.time()
self.jobs_directory = Path(jobs_directory or Path.cwd() / "jobs")
def add_resource(self, name, resource):
"""Register shared state owned for the full service lifetime."""
if not name or "/" in name:
raise ValueError(f"Invalid resource name: {name}")
with self.lock:
if name in self.resources:
raise ValueError(f"Resource is already registered: {name}")
self.resources[name] = resource
return resource
def resource_snapshot(self, name):
try:
resource = self.resources[name]
except KeyError:
raise KeyError(name) from None
snapshot = getattr(resource, "snapshot", None)
return snapshot() if snapshot is not None else {}
def resource_snapshots(self):
return {
name: self.resource_snapshot(name)
for name in tuple(self.resources)
}
def clear_resource(self, name):
"""Clear an idle service resource without racing a new job submission."""
with self.lock:
if self.active_job_id is not None:
return False
try:
resource = self.resources[name]
except KeyError:
raise KeyError(name) from None
clear = getattr(resource, "clear", None)
if clear is None:
raise TypeError(f"Resource cannot be cleared: {name}")
clear()
return True
def close(self):
for resource in reversed(tuple(self.resources.values())):
close = getattr(resource, "close", None)
if close is not None:
close()
def job(self, job_type):
"""Register ``handler(context, parameters)`` for a public job type."""
def register(handler):
if job_type in self.handlers:
raise ValueError(f"Job type is already registered: {job_type}")
self.handlers[job_type] = handler
return handler
return register
def snapshot(self, instance_id):
with self.lock:
active = self.jobs.get(self.active_job_id)
result = {
"ready": True,
"service": self.name,
"instance_id": instance_id,
"busy": active is not None,
"active_job": active.snapshot() if active else None,
"started_at": self.started_at,
"resources": self.resource_snapshots(),
}
return result
def submit(self, job_type, parameters, *, job_id=None):
job_id = job_id or uuid.uuid4().hex
if job_type not in self.handlers:
raise KeyError(job_type)
if not job_id or Path(job_id).name != job_id or job_id in {".", ".."}:
raise ValueError(f"Invalid job id: {job_id}")
with self.lock:
if self.active_job_id is not None:
return None
if job_id in self.jobs:
raise ValueError(f"Job id already exists: {job_id}")
self.jobs_directory.mkdir(parents=True, exist_ok=True)
directory = self.jobs_directory / job_id
try:
directory.mkdir()
except FileExistsError:
raise ValueError(f"Job directory already exists: {job_id}") from None
context = JobContext(
job_id,
job_type,
parameters,
self.resources,
directory,
)
self.jobs[job_id] = context
self.active_job_id = job_id
self._prune_jobs()
return context
def _prune_jobs(self):
completed = [
identifier
for identifier, context in self.jobs.items()
if identifier != self.active_job_id
and context.snapshot()["state"] in TERMINAL_STATES
]
excess = len(completed) - self.max_job_history
for identifier in completed[:max(excess, 0)]:
self.jobs.pop(identifier, None)
def run(self, context):
context.update(0.0, "Service accepted the task", state="running")
try:
context.check_cancelled()
self.handlers[context.job_type](context, context.parameters)
context.check_cancelled()
if context.snapshot()["state"] not in TERMINAL_STATES:
context.update(1.0, "Task complete", state="succeeded")
except JobCancelled:
context.update(1.0, "Task cancelled", state="cancelled")
except Exception as error:
traceback.print_exc()
context.update(1.0, "Task failed", error=error, state="failed")
finally:
with self.lock:
if self.active_job_id == context.job_id:
self.active_job_id = None
def create_app(self, instance_id):
from fastapi import BackgroundTasks, FastAPI, HTTPException
@asynccontextmanager
async def lifespan(_app):
yield
self.close()
app = FastAPI(
title=self.name,
docs_url=None,
redoc_url=None,
openapi_url=None,
lifespan=lifespan,
)
app.state.job_server = self
@app.get("/health")
def health():
return self.snapshot(instance_id)
@app.get("/resources")
def resources():
return self.resource_snapshots()
@app.get("/resources/{resource_name}")
def resource_status(
resource_name: str,
):
try:
return self.resource_snapshot(resource_name)
except KeyError:
raise HTTPException(status_code=404, detail="Resource was not found")
@app.post("/resources/{resource_name}/clear")
def clear_resource(
resource_name: str,
):
try:
cleared = self.clear_resource(resource_name)
except KeyError:
raise HTTPException(status_code=404, detail="Resource was not found")
except TypeError as error:
raise HTTPException(status_code=405, detail=str(error))
if not cleared:
raise HTTPException(status_code=409, detail="Service is busy")
return {"resource": resource_name, "state": "cleared"}
@app.post("/jobs", status_code=202)
def submit_job(
payload: dict,
background_tasks: BackgroundTasks,
):
job_type = str(payload.get("job_type", payload.get("command", "")))
parameters = payload.get("parameters")
if not job_type or not isinstance(parameters, dict):
raise HTTPException(status_code=422, detail="Invalid job request")
try:
context = self.submit(job_type, parameters)
except KeyError:
raise HTTPException(status_code=422, detail="Unknown job type")
except ValueError as error:
raise HTTPException(status_code=409, detail=str(error))
if context is None:
raise HTTPException(status_code=409, detail="Service is busy")
background_tasks.add_task(self.run, context)
return context.snapshot()
@app.get("/jobs/{job_id}")
def job_status(job_id: str):
context = self.jobs.get(job_id)
if context is None:
raise HTTPException(status_code=404, detail="Job was not found")
return context.snapshot()
@app.delete("/jobs/{job_id}", status_code=202)
def cancel_job(job_id: str):
context = self.jobs.get(job_id)
if context is None or not context.request_cancel():
raise HTTPException(status_code=404, detail="Active job was not found")
return context.snapshot()
@app.post("/shutdown", status_code=202)
def shutdown():
self.shutdown_event.set()
return {"state": "stopping"}
return app
def watch_parent(parent_pid, shutdown_event, interval=1.0):
"""Stop a dedicated service after its owning Blender process exits."""
if not parent_pid:
return
while not shutdown_event.wait(interval):
if not _process_exists(parent_pid):
shutdown_event.set()
return
def _process_exists(process_id):
if os.name != "nt":
try:
os.kill(process_id, 0)
return True
except OSError:
return False
import ctypes
synchronize = 0x00100000
handle = ctypes.windll.kernel32.OpenProcess(synchronize, False, process_id)
if not handle:
return False
try:
return ctypes.windll.kernel32.WaitForSingleObject(handle, 0) == 0x00000102
finally:
ctypes.windll.kernel32.CloseHandle(handle)

View File

@@ -0,0 +1,208 @@
import socket
import subprocess
import time
import uuid
from dataclasses import dataclass
from .client import JobClient
@dataclass
class ServiceConnection:
port: int
pid: int
instance_id: str
client: JobClient
class ServiceController:
"""Own a dedicated local JobServer process and its HTTP connection."""
def __init__(
self,
command_factory,
*,
cwd_factory,
log_path_factory,
host="127.0.0.1",
creationflags=0,
):
self.command_factory = command_factory
self.cwd_factory = cwd_factory
self.log_path_factory = log_path_factory
self.host = host
self.creationflags = creationflags
self.connection = None
self.process = None
self.log_file = None
self.auto_start = False
self.snapshot = {"state": "STOPPED", "message": "Stopped"}
def start(self):
if self.process is not None and self.process.poll() is None:
return self.connection
self._close_process()
port = self._available_port()
instance_id = uuid.uuid4().hex
log_path = self.log_path_factory()
log_path.parent.mkdir(parents=True, exist_ok=True)
self.log_file = log_path.open("a", encoding="utf-8")
command = self.command_factory(port, instance_id)
try:
self.process = subprocess.Popen(
command,
stdout=self.log_file,
stderr=subprocess.STDOUT,
cwd=self.cwd_factory(),
creationflags=self.creationflags,
)
except OSError:
self.log_file.close()
self.log_file = None
raise
client = JobClient(self.host, port)
self.connection = ServiceConnection(
port,
self.process.pid,
instance_id,
client,
)
self.snapshot = {"state": "STARTING", "message": "Starting", "port": port}
return self.connection
def health(self, timeout=0.3):
if self.connection is None:
raise RuntimeError("Service is not running")
health = self.connection.client.request("GET", "/health", timeout=timeout)
if health.get("instance_id") != self.connection.instance_id:
raise RuntimeError("Service instance does not match")
state = "BUSY" if health.get("busy") else "READY"
self.snapshot = {
**health,
"state": state,
"message": "Busy" if state == "BUSY" else "Ready",
"port": self.connection.port,
}
return health
def ensure(self, timeout=60.0):
self.auto_start = True
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
self.health(timeout=0.5)
return self.connection
except RuntimeError:
self._close_finished_process()
if self.process is None:
self.start()
time.sleep(0.05)
raise RuntimeError(f"Service did not become ready; log: {self.log_path_factory()}")
def enable(self):
self.auto_start = True
try:
self.health(timeout=0.2)
except RuntimeError:
self.start()
return self.connection
def stop(self, timeout=10.0):
self.auto_start = False
if self.connection is not None:
try:
self.connection.client.shutdown()
except RuntimeError:
pass
deadline = time.monotonic() + timeout
while self.process is not None and self.process.poll() is None:
if time.monotonic() >= deadline:
self._close_process(terminate=True)
break
time.sleep(0.05)
self._close_process()
self.connection = None
self.snapshot = {"state": "STOPPED", "message": "Stopped"}
def restart(self):
self.stop()
self.auto_start = True
return self.start()
def submit(self, request):
return self.ensure().client.submit_request(request)
def status(self, job_id):
return self.ensure().client.status(job_id)
def cancel(self, job_id):
return self.ensure().client.cancel(job_id)
def resource(self, name):
return self.ensure().client.resource(name)
def clear_resource(self, name):
return self.ensure().client.clear_resource(name)
def detach(self):
self.auto_start = False
self.connection = None
self._close_process(terminate=True)
self.snapshot = {"state": "STOPPED", "message": "Stopped"}
def poll(self, *, available=True):
if not available:
self.snapshot = {"state": "UNAVAILABLE", "message": "Environment is not installed"}
return 2.0
try:
self.health(timeout=0.5)
return 5.0
except RuntimeError as error:
self._close_finished_process()
if not self.auto_start:
self.snapshot = {"state": "STOPPED", "message": "Stopped"}
return 2.0
try:
self.start()
except OSError as start_error:
self.snapshot = {"state": "ERROR", "message": str(start_error)}
return 2.0
self.snapshot = {"state": "STARTING", "message": "Starting"}
return 1.0
def mark_job_complete(self, job_id):
active = self.snapshot.get("active_job")
if active and active.get("job_id") != job_id:
return
if self.snapshot.get("state") == "BUSY":
self.snapshot = {
**self.snapshot,
"busy": False,
"active_job": None,
"state": "READY",
"message": "Ready",
}
def _available_port(self):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener:
listener.bind((self.host, 0))
return listener.getsockname()[1]
def _close_finished_process(self):
if self.process is not None and self.process.poll() is not None:
self._close_process()
self.connection = None
def _close_process(self, terminate=False):
process = self.process
self.process = None
if process is not None and terminate and process.poll() is None:
process.terminate()
try:
process.wait(timeout=3.0)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=3.0)
if self.log_file is not None:
self.log_file.close()
self.log_file = None

View File

@@ -1,26 +1,19 @@
import json
import os
import platform
import secrets
import shutil
import socket
import subprocess
import sys
import tempfile
import threading
import time
import uuid
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.request import ProxyHandler, Request, build_opener
import bpy
from .blendjob import JobRequest, ServiceController
from .env_revision import MANIFEST_FILENAME, MANIFEST_REVISION
from .models import (
BEN2_MODEL,
MOGE2_MODEL,
get_downloadable_model,
model_directory_name,
selected_model_key,
@@ -29,49 +22,60 @@ from .models import (
SERVICE_ADDRESS = "127.0.0.1"
DEFAULT_STORAGE_ROOT = Path.home() / ".snapform"
JOB_FOLDER_NAMES = {
"INSTALL_ENV": "install-environment",
"DOWNLOAD_MODEL": "download-model",
"RECONSTRUCT": "export-reconstruction",
"IMAGE_DEPTH": "generate-depth-plane",
"MOGE2_DEPTH": "generate-moge2-depth-plane",
"MOGE2_MESH": "generate-moge2-surface",
"MOGE2_DEBUG": "test-moge2-api",
"IMAGE_MESH": "generate-image-mesh",
"REMOVE_BACKGROUND": "remove-background",
"IMAGE_BACKGROUND": "remove-image-background",
"ORIGINAL_BACKGROUND": "generate-original-depth-plane",
"ORIGINAL_FRAMES": "extract-original-frames",
}
_service_controller = None
_environment_controller = None
@dataclass(frozen=True)
class JobRequest:
payload: dict
class EnvironmentController:
"""Adapt the local Environment bootstrap to the modal controller protocol."""
def __init__(self):
self.process = None
self.log_file = None
def submit(self, _request):
stop_service()
paths = install_paths()
self.log_file = paths["log"].open("w", encoding="utf-8")
environment = os.environ.copy()
environment["PYTHONIOENCODING"] = "utf-8:backslashreplace"
environment["PYTHONUTF8"] = "1"
self.process = subprocess.Popen(
install_env_command(paths["status"]),
stdout=self.log_file,
stderr=subprocess.STDOUT,
cwd=storage_root(),
creationflags=creation_flags(),
env=environment,
)
return {"job_id": "environment", "directory": str(storage_root())}
def status(self, _job_id):
status = read_bootstrap_status(install_paths()["status"]) or {}
if self.process is None or self.process.poll() is None:
return {**status, "state": "running", "progress": status.get("progress", 0.0)}
if self.process.returncode == 0:
return {"progress": 1.0, "message": "Environment is ready", **status,
"state": "succeeded"}
return {"message": "Environment installation failed", **status,
"state": "failed", "error": status.get("error", "Bootstrap failed")}
def cancel(self, _job_id):
if self.process is not None and self.process.poll() is None:
self.process.terminate()
return {"state": "cancelled"}
def mark_job_complete(self, _job_id):
if self.log_file is not None:
self.log_file.close()
self.log_file = None
@dataclass
class Connection:
port: int
token: str
pid: int
instance_id: str
@dataclass
class ServiceProcess:
process: subprocess.Popen
log_file: object
_service_connection = None
_launched_service = None
_registered_instance_id = None
_client_id = uuid.uuid4().hex
_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()
def environment_controller():
global _environment_controller
if _environment_controller is None:
_environment_controller = EnvironmentController()
return _environment_controller
def preferences():
@@ -207,12 +211,6 @@ def production_device():
return configured or "AUTO"
def jobs_directory():
path = storage_root() / "jobs"
path.mkdir(parents=True, exist_ok=True)
return path
def temporary_input_path():
directory = Path(tempfile.mkdtemp(prefix="snapform-input-"))
return directory / "input.png"
@@ -222,52 +220,17 @@ def temporary_input_directory():
return Path(tempfile.mkdtemp(prefix="snapform-input-"))
def move_input_into_job(input_path, job_directory):
"""Move a Blender-generated input copy into the flat job directory."""
input_path = Path(input_path)
job_directory = Path(job_directory)
if input_path.is_file():
destination = job_directory / "input.png"
input_path.replace(destination)
try:
input_path.parent.rmdir()
except OSError:
pass
return destination
if not input_path.is_dir():
raise ValueError(f"Temporary input does not exist: {input_path}")
for source in input_path.iterdir():
if not source.is_file():
raise ValueError(f"Temporary input contains a directory: {source}")
destination = job_directory / source.name
if destination.exists():
raise ValueError(f"Job input filename already exists: {source.name}")
source.replace(destination)
input_path.rmdir()
return job_directory
def job_folder_name(job_type):
"""Return the folder name segment for a job type."""
return JOB_FOLDER_NAMES.get(job_type, job_type.lower())
def new_job_paths(job_type):
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S-%f")
stem = f"{job_folder_name(job_type)}_{timestamp}"
directory = jobs_directory() / stem
directory.mkdir(parents=True, exist_ok=True)
def install_paths():
"""Return shared bootstrap paths outside the BlendJob workspace."""
root = storage_root()
return {
"log": directory / "job.log",
"status": directory / "status.json",
"result": directory,
"job_id": "",
"log": root / "environment.log",
"status": root / "environment-status.json",
"result": root,
}
def job_id_from_status(status_path):
return Path(status_path).parent.name
def uv_executable():
"""Return the downloaded uv path inside the shared storage."""
return str(uv_cache_path())
@@ -303,282 +266,63 @@ def service_log_path():
return storage_root() / "service.log"
def service_discovery_path():
return storage_root() / "service.json"
def service_lock_path():
return storage_root() / "service.lock"
def _available_service_port():
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener:
listener.bind((SERVICE_ADDRESS, 0))
return listener.getsockname()[1]
def _service_url(connection, path):
return f"http://{SERVICE_ADDRESS}:{connection.port}{path}"
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(
_service_url(connection, path),
data=data,
headers=headers,
method=method,
)
try:
with _local_opener.open(request, timeout=timeout) as response:
return json.loads(response.read().decode("utf-8"))
except HTTPError as error:
try:
detail = json.loads(error.read().decode("utf-8")).get("detail")
except (json.JSONDecodeError, UnicodeDecodeError):
detail = None
raise RuntimeError(
detail or f"Service returned HTTP {error.code}"
)
except (OSError, URLError) as error:
raise RuntimeError(f"Unable to reach Service: {error}")
def _read_service_discovery():
try:
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"]),
instance_id=str(payload["instance_id"]),
)
except (KeyError, TypeError, ValueError, OSError, json.JSONDecodeError):
return None
def _check_service(connection, timeout=0.3):
health = _service_http_request(
connection,
"GET",
"/health",
timeout=timeout,
)
if health.get("instance_id") != connection.instance_id:
raise RuntimeError("Service discovery is stale")
return health
def _discover_live_service(timeout=0.3):
connection = _read_service_discovery()
if connection is None:
return None, None
try:
return connection, _check_service(connection, timeout=timeout)
except RuntimeError:
return None, None
def start_service():
global _launched_service
if _launched_service is not None and _launched_service.process.poll() is None:
return
_close_launched_service()
port = _available_service_port()
token = secrets.token_urlsafe(32)
instance_id = uuid.uuid4().hex
log_file = service_log_path().open("a", encoding="utf-8")
command = [
def _service_command(port, instance_id):
return [
str(venv_python()),
"-u",
str(Path(__file__).with_name("service.py")),
str(Path(__file__).with_name("ai_service") / "server.py"),
"--host",
SERVICE_ADDRESS,
"--port",
str(port),
"--token",
token,
"--instance-id",
instance_id,
"--discovery",
str(service_discovery_path()),
"--lock",
str(service_lock_path()),
"--parent-pid",
str(os.getpid()),
]
try:
process = subprocess.Popen(
command,
stdout=log_file,
stderr=subprocess.STDOUT,
cwd=storage_root(),
def service_controller():
global _service_controller
if _service_controller is None:
_service_controller = ServiceController(
_service_command,
cwd_factory=storage_root,
log_path_factory=service_log_path,
host=SERVICE_ADDRESS,
creationflags=creation_flags(),
)
except OSError:
log_file.close()
raise
_launched_service = ServiceProcess(process, log_file)
return _service_controller
def start_service():
return service_controller().start()
def ensure_service(timeout=60.0):
global _service_auto_start, _service_connection, _service_snapshot
_service_auto_start = True
deadline = time.monotonic() + timeout
last_launch = 0.0
starting = False
while time.monotonic() < deadline:
connection, health = _discover_live_service(timeout=0.5)
if connection is not None:
_service_connection = connection
_register_service_client(connection)
_service_snapshot = _format_service_snapshot(connection, health)
_redraw_service_ui()
return connection
if not starting:
_service_snapshot = {"state": "STARTING", "message": "Starting"}
_redraw_service_ui()
starting = True
_close_finished_launched_service()
if (
_launched_service is None
and time.monotonic() - last_launch >= 1.0
):
start_service()
last_launch = time.monotonic()
time.sleep(0.05)
raise RuntimeError(
f"Service did not become ready; log: {service_log_path()}"
)
def submit_service_job(request):
connection = _service_connection or ensure_service()
payload = dict(request.payload)
payload["client_id"] = _client_id
_service_http_request(
connection,
"POST",
"/jobs",
payload=payload,
timeout=5.0,
)
connection = service_controller().ensure(timeout)
_redraw_service_ui()
return connection
def stop_service():
global _service_auto_start, _service_connection, _service_snapshot
global _registered_instance_id
connection = _service_connection
if connection is None:
connection, _health = _discover_live_service(timeout=0.5)
if connection is None:
_close_launched_service(terminate=True)
_service_auto_start = False
_service_snapshot = {"state": "STOPPED", "message": "Stopped"}
return
_register_service_client(connection)
_service_http_request(
connection,
"POST",
"/shutdown",
payload={"client_id": _client_id},
timeout=2.0,
)
_service_auto_start = False
deadline = time.monotonic() + 10.0
while time.monotonic() < deadline:
try:
_check_service(connection, timeout=0.2)
except RuntimeError:
break
time.sleep(0.05)
else:
raise RuntimeError("Service did not stop")
_service_connection = None
_registered_instance_id = None
_close_finished_launched_service()
_service_snapshot = {"state": "STOPPED", "message": "Stopped"}
service_controller().stop()
def restart_service():
global _service_auto_start, _service_connection, _registered_instance_id
global _service_snapshot
connection = ensure_service()
_service_http_request(
connection,
"POST",
"/shutdown",
payload={"client_id": _client_id},
timeout=2.0,
)
_service_auto_start = True
_service_connection = None
_registered_instance_id = None
_service_snapshot = {"state": "STARTING", "message": "Restarting"}
return service_controller().restart()
def enable_service():
global _service_auto_start, _service_connection, _service_snapshot
_service_auto_start = True
connection, health = _discover_live_service(timeout=0.2)
if connection is not None:
_service_connection = connection
_register_service_client(connection)
_service_snapshot = _format_service_snapshot(connection, health)
_redraw_service_ui()
return
start_service()
_service_snapshot = {"state": "STARTING", "message": "Starting"}
_redraw_service_ui()
return service_controller().enable()
def clear_service_models():
connection = ensure_service()
return _service_http_request(
connection,
"POST",
"/models/clear",
timeout=5.0,
)
def cancel_service_job(job_id):
connection = ensure_service()
return _service_http_request(
connection,
"DELETE",
f"/jobs/{job_id}",
payload={"client_id": _client_id},
timeout=2.0,
)
return ensure_service().client.clear_resource("models")
def detach_service_client():
global _service_connection, _registered_instance_id
connection = _service_connection
if connection is not None and _registered_instance_id is not None:
try:
_service_http_request(
connection,
"DELETE",
f"/clients/{_client_id}",
timeout=0.5,
)
except RuntimeError:
pass
_service_connection = None
_registered_instance_id = None
# 当前 Blender 启动的 Service 随 Blender 关闭,不等待空闲超时。
_close_launched_service(terminate=True)
service_controller().detach()
def schedule_service_start():
@@ -599,104 +343,17 @@ def unschedule_service():
def service_status():
return dict(_service_snapshot)
return dict(service_controller().snapshot)
def service_busy():
return _service_snapshot.get("state") == "BUSY"
def mark_service_job_complete(job_id):
"""Release a stale local busy snapshot after its terminal status is observed."""
global _service_snapshot
active = _service_snapshot.get("active_job")
if active and active.get("job_id") != job_id:
return
if _service_snapshot.get("state") != "BUSY":
return
_service_snapshot = {
**_service_snapshot,
"busy": False,
"active_job": None,
"state": "READY",
"message": "Ready",
}
return service_controller().snapshot.get("state") == "BUSY"
def _service_timer():
global _service_connection, _service_snapshot, _registered_instance_id
if not env_installed():
_service_snapshot = {
"state": "UNAVAILABLE",
"message": "Environment is not installed",
}
return 2.0
connection, health = _discover_live_service(timeout=0.2)
if connection is None:
_service_connection = None
_registered_instance_id = None
_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_service()
except OSError as error:
_service_snapshot = {"state": "ERROR", "message": str(error)}
return 2.0
_service_snapshot = {"state": "STARTING", "message": "Starting"}
_redraw_service_ui()
return 1.0
try:
_service_connection = connection
if _registered_instance_id == connection.instance_id:
try:
_service_http_request(
connection,
"POST",
f"/clients/{_client_id}/heartbeat",
timeout=0.5,
)
except RuntimeError:
_registered_instance_id = None
_register_service_client(connection)
health = _check_service(connection, timeout=0.5)
_service_snapshot = _format_service_snapshot(connection, health)
except RuntimeError as error:
_service_connection = None
_registered_instance_id = None
_service_snapshot = {"state": "ERROR", "message": str(error)}
interval = service_controller().poll(available=env_installed())
_redraw_service_ui()
return 5.0
def _register_service_client(connection):
global _registered_instance_id
if _registered_instance_id == connection.instance_id:
return
_service_http_request(
connection,
"POST",
"/clients",
payload={"client_id": _client_id, "label": _client_label},
timeout=2.0,
)
_registered_instance_id = connection.instance_id
def _format_service_snapshot(connection, health):
state = "BUSY" if health.get("busy") else "READY"
return {
**health,
"state": state,
"message": "Busy" if state == "BUSY" else "Ready",
"port": connection.port,
}
return interval
def _redraw_service_ui():
@@ -710,155 +367,92 @@ def _redraw_service_ui():
pass
def _close_finished_launched_service():
if _launched_service is None or _launched_service.process.poll() is None:
return
_close_launched_service()
def download_model(model_key):
get_downloadable_model(model_key)
return JobRequest("download-model", {"model": model_key})
def _close_launched_service(terminate=False):
global _launched_service
launched = _launched_service
_launched_service = None
if launched is None:
return
if terminate and launched.process.poll() is None:
launched.process.terminate()
try:
launched.process.wait(timeout=3.0)
except subprocess.TimeoutExpired:
launched.process.kill()
launched.process.wait(timeout=3.0)
launched.log_file.close()
def download_model_command(model_key, status_path):
model = get_downloadable_model(model_key)
command = [
str(venv_python()),
str(Path(__file__).with_name("worker.py")),
"download",
"--repository",
model.repository,
"--model-dir",
str(model_directory(model_key)),
"--status",
str(status_path),
"--label",
model.label,
]
for pattern in model.download_patterns:
command.extend(["--allow-pattern", pattern])
for pattern in model.ready_patterns:
command.extend(["--required-pattern", pattern])
if model.r2_directory:
command.extend(["--r2-directory", model.r2_directory])
for filename, size, checksum in model.r2_files:
command.extend(
["--r2-file", f"{filename}|{size}|{checksum}"]
)
return command
def remove_background_request(
settings,
input_path,
status_path,
result_directory,
model_key,
video_frames=None,
start_frame=1,
device=None,
):
return JobRequest(
{
"job_id": job_id_from_status(status_path),
"command": "remove-background",
"parameters": {
"input": str(input_path),
"model_dir": str(model_directory(model_key)),
"output_dir": str(result_directory),
"status": str(status_path),
"device": (device or settings.device).lower(),
"video_frames": (
settings.video_frames
if video_frames is None
else video_frames
),
"start_frame": start_frame,
},
}
def remove_background(settings, input_path, model_key, *, start_frame=1, device=None):
return _background_request(
"remove-background", settings, input_path, model_key, start_frame, device
)
def extract_frames_request(
settings,
input_path,
status_path,
result_directory,
video_frames=None,
start_frame=1,
):
return JobRequest(
{
"job_id": job_id_from_status(status_path),
"command": "extract-frames",
"parameters": {
"input": str(input_path),
"output_dir": str(result_directory),
"status": str(status_path),
"video_frames": (
settings.video_frames
if video_frames is None
else video_frames
),
"start_frame": start_frame,
},
}
def debug_ben2(settings, input_path, model_key, *, start_frame=1, device=None):
return _background_request(
"debug-ben2", settings, input_path, model_key, start_frame, device
)
def reconstruct_request(
settings,
export_format,
status_path,
result_directory,
input_path=None,
model_key=None,
process_res=None,
process_res_method=None,
use_ray_pose=None,
remove_background=False,
mesh_filter_edges=None,
start_frame=1,
device=None,
inference_mode="joint",
):
snapform_selected_key = model_key or selected_model_key(settings, export_format)
snapform_selected_process_res = (
settings.process_res if process_res is None else process_res
)
snapform_selected_process_res_method = (
settings.process_res_method
if process_res_method is None
else process_res_method
)
snapform_selected_ray_pose = (
settings.use_ray_pose if use_ray_pose is None else use_ray_pose
)
parameters = {
"input": str(
Path(input_path)
if input_path is not None
else Path(bpy.path.abspath(settings.input_path))
def _background_request(job_type, settings, input_path, model_key, start_frame, device):
return JobRequest(job_type, {
"input": str(input_path),
"model": model_key,
"device": (device or settings.device).lower(),
"video_frames": getattr(settings, "video_frames", 1),
"start_frame": start_frame,
})
def extract_frames(settings, input_path, *, start_frame=1):
return JobRequest("extract-frames", {
"input": str(input_path),
"video_frames": settings.video_frames,
"start_frame": start_frame,
})
def generate_da3_depth(settings, input_path, model_key, *, process_res, use_ray_pose,
remove_background=False, start_frame=1, device=None):
return JobRequest("generate-da3-depth", {
**_da3_common(settings, input_path, model_key, process_res, device, start_frame),
"export_format": "mini_npz",
"use_ray_pose": 1 if use_ray_pose else 0,
"inference_mode": "per_frame",
"remove_background_model": BEN2_MODEL.key if remove_background else "",
})
def generate_da3_surface(settings, input_path, model_key, *, process_res, use_ray_pose,
remove_background=False, start_frame=1, device=None):
return JobRequest("generate-da3-surface", {
**_da3_common(settings, input_path, model_key, process_res, device, start_frame),
"export_format": "mesh_data",
"use_ray_pose": 1 if use_ray_pose else 0,
"inference_mode": "per_frame",
"remove_background_model": BEN2_MODEL.key if remove_background else "",
"mesh_filter_edges": bool(getattr(settings, "mesh_filter_edges", True)),
"mesh_max_edge_length": float(getattr(settings, "mesh_max_edge_length", 0.1)),
})
def debug_da3(settings, input_path=None, model_key=None, *, device=None):
export_format = settings.export_format
selected_key = model_key or selected_model_key(settings, export_format)
return JobRequest("debug-da3", {
**_da3_common(
settings,
input_path or Path(bpy.path.abspath(settings.input_path)),
selected_key,
settings.process_res,
device,
1,
),
"model_dir": str(model_directory(snapform_selected_key)),
"output_dir": str(result_directory),
"export_format": export_format,
"status": str(status_path),
"process_res": snapform_selected_process_res,
"process_res_method": snapform_selected_process_res_method,
"use_ray_pose": 1 if settings.use_ray_pose else 0,
"inference_mode": "joint",
"remove_background_model": "",
"mesh_filter_edges": bool(getattr(settings, "mesh_filter_edges", True)),
"mesh_max_edge_length": float(getattr(settings, "mesh_max_edge_length", 0.1)),
})
def _da3_common(settings, input_path, model_key, process_res, device, start_frame):
return {
"input": str(input_path),
"model": model_key,
"process_res": process_res,
"process_res_method": "upper_bound_resize",
"conf_thresh_percentile": settings.conf_thresh_percentile,
"num_max_points": settings.num_max_points,
"show_cameras": 1 if settings.show_cameras else 0,
@@ -866,83 +460,52 @@ def reconstruct_request(
"video_frames": settings.video_frames,
"start_frame": start_frame,
"device": (device or settings.device).lower(),
"use_ray_pose": 1 if snapform_selected_ray_pose else 0,
"ref_view_strategy": settings.ref_view_strategy,
"inference_mode": inference_mode,
"remove_background_model_dir": "",
"mesh_filter_edges": bool(
getattr(settings, "mesh_filter_edges", True)
if mesh_filter_edges is None
else mesh_filter_edges
),
"mesh_max_edge_length": float(
getattr(settings, "mesh_max_edge_length", 0.1)
),
}
if remove_background:
parameters["remove_background_model_dir"] = str(
model_directory(BEN2_MODEL.key)
)
return JobRequest(
{
"job_id": job_id_from_status(status_path),
"command": "reconstruct",
"parameters": parameters,
}
)
def moge_request(
settings,
status_path,
result_directory,
input_path=None,
resolution_level=None,
include_points=False,
remove_background=False,
start_frame=1,
device=None,
model_key=None,
debug_export_format="",
surface_mesh=False,
):
from .models import BEN2_MODEL, MOGE2_MODEL
def generate_moge2_depth(settings, input_path, model_key=MOGE2_MODEL.key, *,
resolution_level=5, remove_background=False,
start_frame=1, device=None):
return JobRequest("generate-moge2-depth", {
**_moge2_common(settings, input_path, model_key, resolution_level, device, start_frame),
"remove_background_model": BEN2_MODEL.key if remove_background else "",
})
selected_model_key = model_key or MOGE2_MODEL.key
parameters = {
"input": str(
Path(input_path)
if input_path is not None
else Path(bpy.path.abspath(settings.input_path))
def generate_moge2_surface(settings, input_path, model_key=MOGE2_MODEL.key, *,
resolution_level=5, remove_background=False,
start_frame=1, device=None):
return JobRequest("generate-moge2-surface", {
**_moge2_common(settings, input_path, model_key, resolution_level, device, start_frame),
"remove_background_model": BEN2_MODEL.key if remove_background else "",
})
def debug_moge2(settings, input_path=None, model_key=None, *, device=None):
return JobRequest("debug-moge2", {
**_moge2_common(
settings,
input_path or Path(bpy.path.abspath(settings.input_path)),
model_key or settings.moge_model,
settings.moge_resolution_level,
device,
1,
),
"model_dir": str(model_directory(selected_model_key)),
"output_dir": str(result_directory),
"status": str(status_path),
"resolution_level": int(
settings.moge_resolution_level
if resolution_level is None
else resolution_level
),
"include_points": bool(include_points),
"debug_export_format": debug_export_format,
"surface_mesh": bool(surface_mesh),
"export_format": settings.moge_export_format,
"include_points": True,
})
def _moge2_common(settings, input_path, model_key, resolution_level, device, start_frame):
return {
"input": str(input_path),
"model": model_key,
"resolution_level": int(resolution_level),
"video_frames": settings.video_frames,
"start_frame": start_frame,
"device": (device or settings.device).lower(),
"remove_background_model_dir": (
str(model_directory(BEN2_MODEL.key))
if remove_background
else ""
),
}
return JobRequest(
{
"job_id": job_id_from_status(status_path),
"command": "moge",
"parameters": parameters,
}
)
def creation_flags():
@@ -951,7 +514,7 @@ def creation_flags():
return subprocess.CREATE_NO_WINDOW
def read_status(path):
def read_bootstrap_status(path):
try:
return json.loads(Path(path).read_text(encoding="utf-8"))
except (FileNotFoundError, json.JSONDecodeError, OSError):

View File

@@ -391,7 +391,7 @@ def install_stages(args, venv_dir, python, uv):
),
]
models = DOWNLOADABLE_MODELS
worker_script = Path(__file__).with_name("worker.py")
worker_script = Path(__file__).with_name("ai_service") / "jobs.py"
models_dir = Path(args.models_dir)
for model_key in DEFAULT_MODEL_KEYS:
model = models[model_key]

File diff suppressed because it is too large Load Diff

View File

@@ -24,12 +24,11 @@ class AiServicePanel(bpy.types.Panel):
)
install_row = layout.row()
install_row.enabled = not running
operator = install_row.operator(
"snapform.run_job",
install_row.operator(
"snapform.install_environment",
text="Install Environment",
icon="IMPORT",
)
operator.job_type = "INSTALL_ENV"
return
status = client.service_status()
@@ -46,7 +45,7 @@ class AiServicePanel(bpy.types.Panel):
if state in {"READY", "BUSY"}:
row.label(
text=(
f"Running (Clients: {status.get('clients', 0)})"
"Running"
if state == "READY"
else "Running (Busy)"
),
@@ -54,7 +53,6 @@ class AiServicePanel(bpy.types.Panel):
)
if state == "READY":
controls = row.row(align=True)
controls.enabled = status.get("clients", 0) <= 1
operator = controls.operator(
"snapform.control_service",
text="",
@@ -88,7 +86,11 @@ class AiServicePanel(bpy.types.Panel):
operator.action = "START"
if state in {"READY", "BUSY"}:
cached = status.get("cached_models", {})
cached = (
status.get("resources", {})
.get("models", {})
.get("loaded", {})
)
cached_names = [name for name in cached.values() if name]
cached_row = layout.row(align=True)
cached_row.label(
@@ -142,11 +144,10 @@ class ModelsPanel(bpy.types.Panel):
download_row = row.row(align=True)
download_row.enabled = env_installed() and not running
operator = download_row.operator(
"snapform.run_job",
"snapform.download_model",
text="Download",
icon="IMPORT",
)
operator.job_type = "DOWNLOAD_MODEL"
operator.model_key = model.key
@@ -231,12 +232,11 @@ class Da3OutputDebugPanel(bpy.types.Panel):
and not client.service_busy()
and model_ready(selected_model_key(settings))
)
operator = export_row.operator(
"snapform.run_job",
export_row.operator(
"snapform.debug_da3",
text="Generate Output",
icon="EXPORT",
)
operator.job_type = "RECONSTRUCT"
class Moge2ApiDebugPanel(bpy.types.Panel):
@@ -262,12 +262,11 @@ class Moge2ApiDebugPanel(bpy.types.Panel):
and not client.service_busy()
and model_ready(settings.moge_model)
)
operator = run_row.operator(
"snapform.run_job",
run_row.operator(
"snapform.debug_moge2",
text="Generate MoGe-2 Output",
icon="EXPORT",
)
operator.job_type = "MOGE2_DEBUG"
class Ben2RemovalDebugPanel(bpy.types.Panel):
@@ -291,9 +290,8 @@ class Ben2RemovalDebugPanel(bpy.types.Panel):
and not client.service_busy()
and model_ready(BEN2_MODEL.key)
)
operator = background_row.operator(
"snapform.run_job",
background_row.operator(
"snapform.debug_ben2",
text="Remove Background",
icon="IMAGE_ALPHA",
)
operator.job_type = "REMOVE_BACKGROUND"

View File

@@ -41,12 +41,11 @@ class SnapFormPreferences(bpy.types.AddonPreferences):
else:
install_row = layout.row()
install_row.enabled = active_job() is None
operator = install_row.operator(
"snapform.run_job",
install_row.operator(
"snapform.install_environment",
text="Install Environment",
icon="IMPORT",
)
operator.job_type = "INSTALL_ENV"
layout.separator()
operator = layout.operator(
"snapform.control_service",

View File

@@ -1,598 +0,0 @@
import argparse
import gc
import hmac
import json
import os
import sys
import threading
import time
import traceback
from contextlib import asynccontextmanager
from pathlib import Path
from types import SimpleNamespace
SCRIPT_DIRECTORY = Path(__file__).resolve().parent
if str(SCRIPT_DIRECTORY) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIRECTORY))
import worker
CLIENT_TIMEOUT_SECONDS = 10.0
JOB_COMMANDS = frozenset(
{"remove-background", "reconstruct", "extract-frames", "moge"}
)
IDLE_SHUTDOWN_SECONDS = 20.0
class ServiceLock:
def __init__(self, path):
self.path = Path(path)
self.file = None
def acquire(self):
self.path.parent.mkdir(parents=True, exist_ok=True)
lock_file = self.path.open("a+b")
lock_file.seek(0, os.SEEK_END)
if lock_file.tell() == 0:
lock_file.write(b"0")
lock_file.flush()
lock_file.seek(0)
try:
if sys.platform == "win32":
import msvcrt
msvcrt.locking(lock_file.fileno(), msvcrt.LK_NBLCK, 1)
else:
import fcntl
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
lock_file.close()
return False
self.file = lock_file
return True
def release(self):
if self.file is None:
return
try:
self.file.seek(0)
if sys.platform == "win32":
import msvcrt
msvcrt.locking(self.file.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl
fcntl.flock(self.file.fileno(), fcntl.LOCK_UN)
finally:
self.file.close()
self.file = None
class ModelCache:
def __init__(self):
self.ben2_model = None
self.ben2_key = None
self.da3_model = None
self.da3_key = None
self.moge_model = None
self.moge_key = None
def get_ben2(self, model_dir, device):
requested_device = getattr(device, "type", device)
key = (str(Path(model_dir).resolve()), requested_device, "onnx")
if self.ben2_model is not None and self.ben2_key == key:
return self.ben2_model, 0.0
self.release_ben2()
return self._load_ben2(model_dir, requested_device, key)
def _load_ben2(self, model_dir, requested_device, key):
from ben2_onnx import create_session
started = time.perf_counter()
model = create_session(model_dir, requested_device)
self.ben2_model = model
self.ben2_key = key
elapsed_ms = (time.perf_counter() - started) * 1000.0
return model, elapsed_ms
def get_da3(self, model_dir, device):
requested_device = getattr(device, "type", device)
key = (str(Path(model_dir).resolve()), requested_device, "onnx")
if self.moge_model is not None:
self.release_moge()
if self.da3_model is not None and self.da3_key == key:
return self.da3_model
self.release_da3()
return self._load_da3(model_dir, requested_device, key)
def _load_da3(self, model_dir, requested_device, key):
from da3_onnx import create_session
model = create_session(model_dir, requested_device)
self.da3_model = model
self.da3_key = key
return model
def get_moge_onnx(self, model_dir, requested_device):
from moge_onnx import create_session
key = (str(Path(model_dir).resolve()), requested_device, "onnx")
if self.da3_model is not None:
self.release_da3()
if self.moge_model is not None and self.moge_key == key:
return self.moge_model
self.release_moge()
model = create_session(model_dir, requested_device)
self.moge_model = model
self.moge_key = key
return model
def release_ben2(self):
self.ben2_model = None
self.ben2_key = None
self._release_cuda_cache()
def release_da3(self):
self.da3_model = None
self.da3_key = None
self._release_cuda_cache()
def release_moge(self):
self.moge_model = None
self.moge_key = None
self._release_cuda_cache()
def close(self):
self.ben2_model = None
self.da3_model = None
self.moge_model = None
self.ben2_key = None
self.da3_key = None
self.moge_key = None
self._release_cuda_cache()
def status(self):
return {
"ben2": (
Path(self.ben2_key[0]).name if self.ben2_key is not None else ""
),
"da3": (
Path(self.da3_key[0]).name
if self.da3_key is not None
else ""
),
"moge2": (
Path(getattr(self, "moge_key", ("",))[0]).name
if getattr(self, "moge_key", None) is not None
else ""
),
}
def _release_cuda_cache(self):
gc.collect()
class ServiceState:
def __init__(self, token, instance_id):
self.token = token
self.instance_id = instance_id
self.models = ModelCache()
self.lock = threading.Lock()
self.clients = {}
self.active_job = None
self.started_at = time.time()
self.last_without_clients = time.monotonic()
self.shutdown_event = threading.Event()
self.monitor_stop = threading.Event()
self.monitor_thread = None
def register_client(self, client_id, label):
with self.lock:
self.clients[client_id] = {
"label": label,
"last_seen": time.monotonic(),
}
self.last_without_clients = None
def heartbeat(self, client_id):
with self.lock:
client = self.clients.get(client_id)
if client is None:
return False
client["last_seen"] = time.monotonic()
return True
def unregister_client(self, client_id):
with self.lock:
self.clients.pop(client_id, None)
self._mark_empty_clients()
def reserve(self, job_id, client_id, command, status_path):
with self.lock:
if client_id not in self.clients or self.active_job is not None:
return False
self.active_job = {
"job_id": job_id,
"client_id": client_id,
"command": command,
"status": status_path,
"cancel_requested": False,
}
return True
def snapshot(self):
self.expire_clients()
with self.lock:
return {
"ready": True,
"instance_id": self.instance_id,
"busy": self.active_job is not None,
"active_job": dict(self.active_job) if self.active_job else None,
"clients": len(self.clients),
"cached_models": self.models.status(),
"started_at": self.started_at,
}
def clear_models(self):
with self.lock:
if self.active_job is not None:
return False
self.models.close()
return True
def request_shutdown(self, client_id):
with self.lock:
if client_id not in self.clients:
return "missing"
if len(self.clients) > 1:
return "shared"
if self.active_job is not None:
return "busy"
self.shutdown_event.set()
return "accepted"
def cancel_job(self, job_id, client_id):
with self.lock:
active = self.active_job
if active is None or active["job_id"] != job_id:
return None
if active["client_id"] != client_id:
return None
active["cancel_requested"] = True
return dict(active)
def is_job_cancelled(self, job_id):
with self.lock:
active = self.active_job
return bool(
active is not None
and active["job_id"] == job_id
and active["cancel_requested"]
)
def run_job(self, payload):
job_id = payload["job_id"]
parameters = payload["parameters"]
status_path = parameters["status"]
worker.write_status(
status_path,
0.0,
"Service accepted the task",
state="running",
)
try:
cancel_check = lambda: self.is_job_cancelled(job_id)
worker.check_cancelled(cancel_check)
args = SimpleNamespace(**parameters)
if payload["command"] == "remove-background":
worker.remove_background(
args,
model_cache=self.models,
cancel_check=cancel_check,
)
elif payload["command"] == "extract-frames":
worker.extract_frames(
args,
cancel_check=cancel_check,
)
elif payload["command"] == "moge":
worker.run_moge(
args,
model_cache=self.models,
cancel_check=cancel_check,
)
else:
worker.reconstruct(
args,
model_cache=self.models,
cancel_check=cancel_check,
)
worker.check_cancelled(cancel_check)
except worker.TaskCancelled:
worker.write_status(
status_path,
1.0,
"Task cancelled",
state="cancelled",
)
except Exception as error:
traceback.print_exc()
worker.write_status(
status_path,
1.0,
"Task failed",
str(error),
state="failed",
)
finally:
with self.lock:
if (
self.active_job is not None
and self.active_job["job_id"] == job_id
):
self.active_job = None
self._mark_empty_clients()
def start_monitor(self):
self.monitor_thread = threading.Thread(
target=self._monitor,
name="SnapFormServiceMonitor",
daemon=True,
)
self.monitor_thread.start()
def close(self):
self.monitor_stop.set()
if self.monitor_thread is not None:
self.monitor_thread.join(timeout=1.0)
self.models.close()
def expire_clients(self):
deadline = time.monotonic() - CLIENT_TIMEOUT_SECONDS
with self.lock:
expired = [
client_id
for client_id, client in self.clients.items()
if client["last_seen"] < deadline
]
for client_id in expired:
self.clients.pop(client_id, None)
self._mark_empty_clients()
def _mark_empty_clients(self):
if self.clients:
self.last_without_clients = None
elif self.last_without_clients is None:
self.last_without_clients = time.monotonic()
def _monitor(self):
while not self.monitor_stop.wait(1.0):
self.expire_clients()
with self.lock:
idle = self.active_job is None
empty_since = self.last_without_clients
if not idle or empty_since is None:
continue
if time.monotonic() - empty_since >= IDLE_SHUTDOWN_SECONDS:
self.shutdown_event.set()
return
def create_app(token, instance_id="test"):
from fastapi import BackgroundTasks, FastAPI, Header, HTTPException
state = ServiceState(token, instance_id)
@asynccontextmanager
async def lifespan(_app):
state.start_monitor()
yield
state.close()
app = FastAPI(
title="SnapForm Service",
docs_url=None,
redoc_url=None,
openapi_url=None,
lifespan=lifespan,
)
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 service token")
@app.get("/health")
def health(x_snapform_token: str = Header(default="")):
authorize(x_snapform_token)
return state.snapshot()
@app.post("/clients", status_code=201)
def register_client(
payload: dict,
x_snapform_token: str = Header(default=""),
):
authorize(x_snapform_token)
client_id = str(payload.get("client_id", ""))
if not client_id:
raise HTTPException(status_code=422, detail="Missing client id")
state.register_client(client_id, str(payload.get("label", "Blender")))
return {"client_id": client_id, "lease_seconds": CLIENT_TIMEOUT_SECONDS}
@app.post("/clients/{client_id}/heartbeat")
def heartbeat(
client_id: str,
x_snapform_token: str = Header(default=""),
):
authorize(x_snapform_token)
if not state.heartbeat(client_id):
raise HTTPException(status_code=404, detail="Client is not registered")
return {"client_id": client_id, "state": "active"}
@app.delete("/clients/{client_id}")
def unregister_client(
client_id: str,
x_snapform_token: str = Header(default=""),
):
authorize(x_snapform_token)
state.unregister_client(client_id)
return {"client_id": client_id, "state": "removed"}
@app.post("/jobs", status_code=202)
def submit_job(
payload: dict,
background_tasks: BackgroundTasks,
x_snapform_token: str = Header(default=""),
):
authorize(x_snapform_token)
job_id = str(payload.get("job_id", ""))
client_id = str(payload.get("client_id", ""))
command = payload.get("command")
parameters = payload.get("parameters")
if not job_id or command not in JOB_COMMANDS:
raise HTTPException(status_code=422, detail="Invalid job request")
if not isinstance(parameters, dict) or not parameters.get("status"):
raise HTTPException(status_code=422, detail="Missing job parameters")
if not state.reserve(
job_id,
client_id,
command,
parameters["status"],
):
raise HTTPException(status_code=409, detail="Service is busy")
worker.write_status(
parameters["status"],
0.0,
"Task queued",
state="queued",
)
background_tasks.add_task(state.run_job, payload)
return {"job_id": job_id, "state": "queued"}
@app.delete("/jobs/{job_id}", status_code=202)
def cancel_job(
job_id: str,
payload: dict,
x_snapform_token: str = Header(default=""),
):
authorize(x_snapform_token)
client_id = str(payload.get("client_id", ""))
active = state.cancel_job(job_id, client_id)
if active is None:
raise HTTPException(status_code=404, detail="Active job was not found")
try:
current = json.loads(
Path(active["status"]).read_text(encoding="utf-8")
)
progress = float(current.get("progress", 0.0))
except (OSError, ValueError, TypeError, json.JSONDecodeError):
progress = 0.0
worker.write_status(
active["status"],
progress,
"Cancelling task",
state="cancelling",
)
return {"job_id": job_id, "state": "cancelling"}
@app.post("/models/clear")
def clear_models(x_snapform_token: str = Header(default="")):
authorize(x_snapform_token)
if not state.clear_models():
raise HTTPException(status_code=409, detail="Service is busy")
return {"state": "cleared"}
@app.post("/shutdown", status_code=202)
def shutdown_service(
payload: dict,
x_snapform_token: str = Header(default=""),
):
authorize(x_snapform_token)
result = state.request_shutdown(str(payload.get("client_id", "")))
if result == "shared":
raise HTTPException(status_code=409, detail="Other Blender clients are connected")
if result == "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"}
return app
def parse_args():
parser = argparse.ArgumentParser()
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, required=True)
parser.add_argument("--token", required=True)
parser.add_argument("--instance-id", required=True)
parser.add_argument("--discovery", required=True)
parser.add_argument("--lock", required=True)
return parser.parse_args()
def write_discovery(args):
path = Path(args.discovery)
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(f"{path.name}.tmp")
temporary.write_text(
json.dumps(
{
"port": args.port,
"token": args.token,
"pid": os.getpid(),
"instance_id": args.instance_id,
}
),
encoding="utf-8",
)
temporary.replace(path)
def remove_discovery(path, instance_id):
path = Path(path)
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (FileNotFoundError, json.JSONDecodeError, OSError):
return
if payload.get("instance_id") != instance_id:
return
path.unlink(missing_ok=True)
def main():
import uvicorn
args = parse_args()
service_lock = ServiceLock(args.lock)
if not service_lock.acquire():
return
try:
app = create_app(args.token, args.instance_id)
config = uvicorn.Config(
app,
host=args.host,
port=args.port,
access_log=False,
log_level="warning",
)
server = uvicorn.Server(config)
def watch_shutdown():
app.state.snapform_service_state.shutdown_event.wait()
server.should_exit = True
threading.Thread(target=watch_shutdown, daemon=True).start()
write_discovery(args)
server.run()
finally:
remove_discovery(args.discovery, args.instance_id)
service_lock.release()
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,12 @@ import numpy as np
from PIL import Image
SOURCE_DIR = Path(__file__).resolve().parents[1] / "src" / "snapform"
SOURCE_DIR = (
Path(__file__).resolve().parents[1]
/ "src"
/ "snapform"
/ "ai_service"
)
sys.path.insert(0, str(SOURCE_DIR))
import ben2_onnx

View File

@@ -8,7 +8,12 @@ import numpy as np
from PIL import Image
SOURCE_DIR = Path(__file__).resolve().parents[1] / "src" / "snapform"
SOURCE_DIR = (
Path(__file__).resolve().parents[1]
/ "src"
/ "snapform"
/ "ai_service"
)
sys.path.insert(0, str(SOURCE_DIR))
import da3_onnx

View File

@@ -13,7 +13,7 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1]
def load_worker():
specification = importlib.util.spec_from_file_location(
"snapform_download_worker",
PROJECT_ROOT / "src" / "snapform" / "worker.py",
PROJECT_ROOT / "src" / "snapform" / "ai_service" / "jobs.py",
)
module = importlib.util.module_from_spec(specification)
specification.loader.exec_module(module)

View File

@@ -26,7 +26,11 @@ def load_image_depth():
with patch.dict(sys.modules, {"bpy": fake_bpy}):
specification = importlib.util.spec_from_file_location(
"snapform_image_depth_test",
PROJECT_ROOT / "src" / "snapform" / "image_depth.py",
PROJECT_ROOT
/ "src"
/ "snapform"
/ "blender"
/ "depth_plane.py",
)
module = importlib.util.module_from_spec(specification)
specification.loader.exec_module(module)

View File

@@ -13,7 +13,7 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1]
def load_module():
specification = importlib.util.spec_from_file_location(
"snapform_moge_onnx",
PROJECT_ROOT / "src" / "snapform" / "moge_onnx.py",
PROJECT_ROOT / "src" / "snapform" / "ai_service" / "moge_onnx.py",
)
module = importlib.util.module_from_spec(specification)
specification.loader.exec_module(module)

View File

@@ -52,13 +52,14 @@ class PackTest(unittest.TestCase):
self.assertIn("__init__.py", names)
self.assertIn("blender_manifest.toml", names)
self.assertIn("image_depth.py", names)
self.assertIn("blender/depth_plane.py", names)
self.assertIn("blender/surface.py", names)
self.assertIn(
"assets/O_SnapForm.blend",
names,
)
self.assertIn("worker.py", names)
self.assertIn("service.py", names)
self.assertIn("ai_service/jobs.py", names)
self.assertIn("ai_service/server.py", names)
self.assertNotIn("snapform/__init__.py", names)
self.assertNotIn("importer.py", names)
self.assertFalse(

View File

@@ -13,7 +13,7 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1]
def load_service():
specification = importlib.util.spec_from_file_location(
"snapform_service",
PROJECT_ROOT / "src" / "snapform" / "service.py",
PROJECT_ROOT / "src" / "snapform" / "ai_service" / "server.py",
)
module = importlib.util.module_from_spec(specification)
specification.loader.exec_module(module)
@@ -38,8 +38,15 @@ class ServiceTest(unittest.TestCase):
def setUpClass(cls):
cls.service = load_service()
def setUp(self):
self.jobs_temp = tempfile.TemporaryDirectory()
self.addCleanup(self.jobs_temp.cleanup)
self.jobs_directory = Path(self.jobs_temp.name) / "jobs"
def create_cache(self):
cache = self.service.ModelCache.__new__(self.service.ModelCache)
cache = self.service.ModelSessionCache.__new__(
self.service.ModelSessionCache
)
cache.ben2_model = None
cache.ben2_key = None
cache.da3_model = None
@@ -63,7 +70,7 @@ class ServiceTest(unittest.TestCase):
self.assertIs(first, second)
self.assertEqual(loads, [("model", "cuda")])
self.assertEqual(cache.status()["ben2"], "model")
self.assertEqual(cache.snapshot()["ben2"], "model")
self.assertEqual(cached_load_ms, 0.0)
def test_da3_model_is_reused_for_matching_runtime_key(self):
@@ -86,7 +93,7 @@ class ServiceTest(unittest.TestCase):
self.assertIs(first, second)
self.assertEqual(loads, [("model", "cuda")])
self.assertEqual(cache.status()["da3"], "model")
self.assertEqual(cache.snapshot()["da3"], "model")
def test_depth_model_cache_keeps_only_da3_or_moge(self):
cache = self.create_cache()
@@ -118,141 +125,133 @@ class ServiceTest(unittest.TestCase):
self.assertIsNone(cache.da3_model)
self.assertIsNotNone(cache.ben2_model)
def test_service_tracks_clients_and_rejects_shared_shutdown(self):
def test_dedicated_service_runs_only_one_job_at_a_time(self):
fake_models = SimpleNamespace(
status=lambda: {"ben2": "", "da3": ""},
snapshot=lambda: {"loaded": {"ben2": "", "da3": ""}},
clear=lambda: None,
close=lambda: None,
)
with patch.object(self.service, "ModelCache", return_value=fake_models):
state = self.service.ServiceState("token", "instance")
with patch.object(self.service, "ModelManager", return_value=fake_models):
server = self.service.create_server(jobs_directory=self.jobs_directory)
state.register_client("first", "Blender A")
state.register_client("second", "Blender B")
parameters = {"model": "BASE"}
first = server.submit("debug-da3", parameters, job_id="job")
second = server.submit("debug-da3", parameters, job_id="other")
self.assertEqual(state.snapshot()["clients"], 2)
self.assertEqual(state.request_shutdown("first"), "shared")
self.assertTrue(
state.reserve("job", "first", "reconstruct", "status.json")
self.assertIsNotNone(first)
self.assertIsNone(second)
self.assertTrue(server.snapshot("instance")["busy"])
self.assertNotIn("clients", server.snapshot("instance"))
def test_service_registers_tasks_instead_of_model_names(self):
server = self.service.create_server(jobs_directory=self.jobs_directory)
self.assertEqual(
set(server.handlers),
{
"download-model",
"remove-background",
"extract-frames",
"generate-da3-depth",
"generate-moge2-depth",
"generate-da3-surface",
"generate-moge2-surface",
"debug-da3",
"debug-moge2",
"debug-ben2",
},
)
self.assertFalse(
state.reserve("other", "second", "reconstruct", "other.json")
)
def test_service_accepts_moge_command(self):
self.assertIn("moge", self.service.JOB_COMMANDS)
def test_cancelling_job_keeps_service_and_model_cache_alive(self):
fake_models = SimpleNamespace(
status=lambda: {"ben2": "BEN2_BASE", "da3": ""},
snapshot=lambda: {
"loaded": {"ben2": "BEN2_BASE", "da3": ""}
},
clear=lambda: None,
close=lambda: None,
)
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"
state.reserve(
"job",
"client",
"remove-background",
str(status_path),
)
active = state.cancel_job("job", "client")
self.assertTrue(active["cancel_requested"])
self.assertTrue(state.is_job_cancelled("job"))
state.run_job(
{
"job_id": "job",
"command": "remove-background",
"parameters": {"status": str(status_path)},
}
)
status = self.service.json.loads(
status_path.read_text(encoding="utf-8")
)
with patch.object(self.service, "ModelManager", return_value=fake_models):
server = self.service.create_server(jobs_directory=self.jobs_directory)
context = server.submit("remove-background", {}, job_id="job")
self.assertTrue(context.request_cancel())
self.assertTrue(context.is_cancelled())
server.run(context)
status = context.snapshot()
self.assertEqual(status["state"], "cancelled")
self.assertFalse(state.shutdown_event.is_set())
self.assertEqual(state.snapshot()["cached_models"]["ben2"], "BEN2_BASE")
def test_expired_clients_are_removed(self):
fake_models = SimpleNamespace(
status=lambda: {"ben2": "", "da3": ""},
close=lambda: None,
)
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.service.CLIENT_TIMEOUT_SECONDS + 1.0
self.assertFalse(server.shutdown_event.is_set())
self.assertEqual(
server.snapshot("instance")["resources"]["models"]["loaded"]["ben2"],
"BEN2_BASE",
)
state.expire_clients()
def test_job_progress_is_kept_in_service_memory(self):
server = self.service.JobServer("Test", jobs_directory=self.jobs_directory)
self.assertEqual(state.snapshot()["clients"], 0)
@server.job("example")
def example(context, _parameters):
context.update(0.5, "Half way")
def test_discovery_is_only_removed_by_matching_service(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "service.json"
args = SimpleNamespace(
discovery=str(path),
port=51234,
token="token",
instance_id="owner",
)
with patch.object(self.service.os, "getpid", return_value=42):
self.service.write_discovery(args)
context = server.submit("example", {})
server.run(context)
payload = self.service.json.loads(path.read_text(encoding="utf-8"))
self.assertEqual(payload["port"], 51234)
self.assertEqual(payload["pid"], 42)
self.service.remove_discovery(path, "other")
self.assertTrue(path.is_file())
self.service.remove_discovery(path, "owner")
self.assertFalse(path.exists())
self.assertRegex(context.job_id, r"^[0-9a-f]{32}$")
self.assertEqual(context.directory.name, context.job_id)
self.assertEqual(context.snapshot()["state"], "succeeded")
self.assertEqual(context.snapshot()["progress"], 1.0)
def test_job_server_owns_and_clears_shared_resources(self):
events = []
resource = SimpleNamespace(
snapshot=lambda: {"loaded": "model"},
clear=lambda: events.append("clear"),
close=lambda: events.append("close"),
)
server = self.service.JobServer("Test", jobs_directory=self.jobs_directory)
server.add_resource("models", resource)
@server.job("example")
def example(context, _parameters):
self.assertIs(context.resource("models"), resource)
context = server.submit("example", {}, job_id="job")
self.assertFalse(server.clear_resource("models"))
server.run(context)
self.assertTrue(server.clear_resource("models"))
server.close()
self.assertEqual(
server.snapshot("instance")["resources"]["models"]["loaded"],
"model",
)
self.assertEqual(events, ["clear", "close"])
def test_service_dispatches_frame_extraction_without_models(self):
fake_models = SimpleNamespace(
status=lambda: {"ben2": "", "da3": ""},
snapshot=lambda: {"loaded": {"ben2": "", "da3": ""}},
clear=lambda: None,
close=lambda: None,
)
with patch.object(self.service, "ModelCache", return_value=fake_models):
state = self.service.ServiceState("token", "instance")
state.register_client("client", "Blender")
with patch.object(self.service, "ModelManager", return_value=fake_models):
server = self.service.create_server(jobs_directory=self.jobs_directory)
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
input_directory = root / "frames"
input_directory.mkdir()
(input_directory / "frame-0001.jpg").touch()
(input_directory / "frame-0002.jpg").touch()
result_directory = root / "result"
status_path = root / "status.json"
state.reserve(
"job",
"client",
context = server.submit(
"extract-frames",
str(status_path),
)
state.run_job(
{
"job_id": "job",
"command": "extract-frames",
"parameters": {
"input": str(input_directory),
"output_dir": str(result_directory),
"status": str(status_path),
"video_frames": 16,
"start_frame": 1,
},
}
)
status = self.service.json.loads(
status_path.read_text(encoding="utf-8")
"input": str(input_directory),
"video_frames": 16,
"start_frame": 1,
},
job_id="job",
)
server.run(context)
status = context.snapshot()
self.assertTrue(
(result_directory / "frame.0001.jpg").is_file()
(context.directory / "frame.0001.jpg").is_file()
)
self.assertEqual(status["state"], "succeeded")

View File

@@ -19,7 +19,7 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1]
def load_worker():
specification = importlib.util.spec_from_file_location(
"snapform_worker",
PROJECT_ROOT / "src" / "snapform" / "worker.py",
PROJECT_ROOT / "src" / "snapform" / "ai_service" / "jobs.py",
)
module = importlib.util.module_from_spec(specification)
specification.loader.exec_module(module)
@@ -500,18 +500,18 @@ class WorkerTest(unittest.TestCase):
def test_models_from_r2_roundtrip_preserves_fields(self):
root = PROJECT_ROOT / ".tmp-models-from-r2"
model_dir = root / "models" / "DA3"
discovery = root / "service.json"
runtime_state = root / "runtime.json"
try:
discovery.parent.mkdir(parents=True, exist_ok=True)
discovery.write_text(
json.dumps({"port": 1, "token": "t"}),
runtime_state.parent.mkdir(parents=True, exist_ok=True)
runtime_state.write_text(
json.dumps({"keep": "value"}),
encoding="utf-8",
)
self.assertFalse(self.worker._models_from_r2(model_dir))
self.worker._mark_models_from_r2(model_dir)
self.assertTrue(self.worker._models_from_r2(model_dir))
payload = json.loads(discovery.read_text(encoding="utf-8"))
self.assertEqual(payload["port"], 1)
payload = json.loads(runtime_state.read_text(encoding="utf-8"))
self.assertEqual(payload["keep"], "value")
self.assertTrue(payload["models_from_r2"])
finally:
shutil.rmtree(root, ignore_errors=True)

View File

@@ -6,7 +6,7 @@ import bpy
sys.path.insert(0, str(Path(__file__).parents[1] / "src"))
from snapform.image_depth import (
from snapform.blender.depth_plane import (
DEPTH_PLANE_MATERIAL_NODE_GROUP_NAME,
SURFACE_MATERIAL_NODE_GROUP_NAME,
add_depth_plane_processing,
@@ -17,7 +17,7 @@ from snapform.image_depth import (
modifier_input_identifier,
set_modifier_input,
)
from snapform.image_mesh import load_geometry_node_group
from snapform.blender.surface import load_geometry_node_group
def main():

View File

@@ -7,11 +7,11 @@ import numpy as np
sys.path.insert(0, str(Path(__file__).parents[1] / "src"))
from snapform.image_depth import (
from snapform.blender.depth_plane import (
get_modifier_input,
set_modifier_input,
)
from snapform.image_mesh import create_matching_camera, load_geometry_node_group
from snapform.blender.surface import create_matching_camera, load_geometry_node_group
def set_input(modifier, group, name, value):

View File

@@ -9,7 +9,7 @@ import numpy as np
PROJECT_ROOT = Path(__file__).parents[1]
sys.path.insert(0, str(PROJECT_ROOT / "src"))
from snapform.image_depth import (
from snapform.blender.depth_plane import (
MOGE_DEPTH_PLANE_NODE_GROUP_NAME,
MOGE_MATERIAL_NODE_GROUP_NAME,
build_moge2_depth_plane,

View File

@@ -9,8 +9,8 @@ import numpy as np
PROJECT_ROOT = Path(__file__).parents[1]
sys.path.insert(0, str(PROJECT_ROOT / "src"))
from snapform.image_depth import MOGE_SURFACE_MATERIAL_NODE_GROUP_NAME
from snapform.image_mesh import (
from snapform.blender.depth_plane import MOGE_SURFACE_MATERIAL_NODE_GROUP_NAME
from snapform.blender.surface import (
MOGE_NODE_GROUP_NAME,
build_image_mesh,
prepare_moge_surface_archive,