Add model catalog with specifications for DA3 and MoGe-2 models

- Introduced ModelSpec dataclass to define model attributes.
- Added multiple models including DA3 Small, Base, Giant 1.1, Nested Giant Large 1.1, and MoGe-2 variants.
- Implemented functions to retrieve model details and metadata.
- Included BEN2 model specifications for background removal.
This commit is contained in:
2026-08-17 14:53:25 +08:00
parent c5b77c5c85
commit 1c98b21235
44 changed files with 637 additions and 2709 deletions

1
.gitignore vendored
View File

@@ -8,3 +8,4 @@ build/
.pytest_cache/
/models/
wheels/

100
AGENTS.md
View File

@@ -1,39 +1,81 @@
## 总规范
# SnapForm 开发约定
- 说明性内容(例如提交说明、文档、注释、mermaid),以中文为主,专有名词、约定俗称可用英文
## 总则
## 项目文档
- 说明性内容(提交说明、文档、注释、Mermaid 等)以中文为主,专有名词和约定俗成的表达可使用英文
- 动手前先读 `docs/index.md`;程序边界、调用流程和模块职责见 `docs/development.md#程序设计`
- 修改代码、依赖或执行方式后,同步更新 `README.md` 与对应主题文档,保持文档、测试和实现一致
- 优先选择依赖少、直接、易验证的实现;保留工作区中与当前任务无关的已有改动
- 动手前先读 `docs/index.md`,它是全部文档的入口,按主题链接并帮助理解项目结构与代码逻辑
- 主题映射:功能与使用 `docs/features.md`、Depth Plane `docs/depth-plane.md`、Surface `docs/surface.md`、输出格式 `docs/outputs.md`、Job Server `docs/server.md`、开发与许可证 `docs/development.md`
- 修改代码或执行方式后,同步更新对应主题文档与 `README.md`,保持文档与实现一致
## 项目结构
## Markdown 规范
```text
.
├── README.md # 用户入口与快速上手
├── docs/ # 按功能拆分的设计与开发文档
├── pack.py # 构建各平台 Blender Extension ZIP
├── sync_models.py # 准备并同步 ONNX 模型
├── src/snapform/
│ ├── __init__.py # Blender 注册入口
│ ├── operators.py # Job Operator 与结果导入
│ ├── panel.py # 3D View 面板
│ ├── preferences.py # Extension Preferences
│ ├── properties.py # Scene 设置与动态枚举
│ ├── job_runtime.py # BlendJob Runtime 配置
│ ├── blender/ # Depth Plane、Surface 的 Blender 侧实现
│ ├── server/ # Job、模型、ONNX 推理与输出实现
│ ├── assets/O_SnapForm.blend # Geometry Nodes 与材质资产
│ └── wheels/ # 随扩展分发的 BlendJob wheel
├── tools/ # Blender 资产构建与验证脚本
└── tests/ # Python 自动化测试
```
- 不使用`---`分割器
- `# Heading` 一级标题仅用于开头
- mermaid 节点名用英文字符
文档按主题定位:功能与使用 `docs/features.md`、Depth Plane `docs/depth-plane.md`、Surface `docs/surface.md`、输出格式 `docs/outputs.md`、Job Server `docs/server.md`、程序设计与开发 `docs/development.md`。
## 常见命令
优先在项目根目录使用 Git Bash;Windows 路径在 Bash 命令中使用正斜杠。
```bash
# 安装或同步开发依赖
uv sync
# 运行全部测试
uv run pytest
# 运行插件注册、Manifest 与打包相关的快速测试
uv run pytest tests/test_addon.py tests/test_manifest.py tests/test_pack.py -v
# 构建 Windows、macOS、Linux 扩展包到 dist/
uv run python pack.py
# 本地 BlendJob wheel 内容变化后刷新锁文件
uv lock --refresh
```
资产脚本必须由 Blender 执行;具体入口与验证范围见 `docs/development.md#资产构建与验证`,不要用普通 Python 解释器运行。
## 命名规范
- 变量、函数、Docstring 使用英文;同一概念在代码和文档中使用统一表达
- Python 类使用大驼峰,不添加项目名前缀。Operator 使用动宾结构,如 `ConvertToDepthPlane`、`RemoveImageBackground`;Menu、Panel、AddonPreferences、PropertyGroup 分别使用实际类型后缀,如 `SnapFormImageMenu`、`ServerPanel`、`SnapFormPreferences`、`SnapFormSettings`
- `bl_idname` 与挂载到公共 Blender RNA 类型的 Property 使用 `snapform` 项目前缀:Operator 如 `snapform.convert_to_depth_plane`,Menu 如 `SNAPFORM_MT_image_context`,Panel 如 `SNAPFORM_PT_server`,公共 Property 如 `Scene.snapform_settings`
- Operator 自身 Property 不加项目前缀,使用业务名称,如 `input_path`、`process_res`、`resolution_level`
- 扩展包名使用 Manifest 中的真实名称、版本和平台,例如 `SnapForm.v0.11.2.windows-x64.zip`;节点组名称沿用资产中的 `O DA3 Depth Plane`、`O MoGe2 Surface` 等既有名称
## 代码规范
- 避免嵌套结构
- 特殊的、非常规的需要写注释
- 变量、函数名、Docstring 用全英文
- 同一事物用统一表达
- 使用的依赖越少越好
- 选用简单直接的方式实现
- 代码、执行方式改变,调整已有的文档、计划,而非新建
- 避免不必要的嵌套,优先使用提前返回和小而明确的函数
- 只为特殊、非常规或存在兼容性原因的实现写注释,不复述代码本身
- 删除功能时同时删除无效参数、分支、测试和文档,不保留没有实际消费者的兼容层
- Blender 侧不载入 AI 依赖:UI、Operator 和结果导入放在 `src/snapform/` 或 `src/snapform/blender/`,模型下载、ONNX 推理和输出放在 `src/snapform/server/`
- Job 参数使用可序列化的普通字典;Blender 数据只能在主线程的 Operator 响应阶段读写
- 新增或修改 Blender 类型时同步检查 `src/snapform/__init__.py` 的注册与逆序注销,并补充对应测试
- 依赖版本、wheel 或 Manifest 改变时同步检查 `pyproject.toml`、`uv.lock`、`blender_manifest.toml` 和打包测试
## Shell 规范
## 文档与 Markdown 规范
- 优先使用、提供 Git Bash 执行所有 Shell 命令
- Windows 路径在 Bash 命令中优先使用正斜杠形式
## Blender 开发规范
- 插件、资产库压缩包名用大驼峰,如果需要,用`b45`来表示适用blender版本。比如`OmooLab.v0.1.0.b45.zip`
- Operator、Menu、Panel、AddonPreferences... Class 用大驼峰命名,不加任何前缀。Operator 动宾结构,比如`RenderImage`。Menu、Panel 以它们本身为后缀,比如`RenderPanel`
- 根据项目名提炼项目缩写,作为 bl_idname 和 自定义 Property 的前缀以区分其他功能和属性,以`Omoo`为例
- bl_idname 用下划线小写命名,Operator 的 bl_idname 以`omoo.*`为前缀,比如`omoo.render_panel`;Menu 的以`OMOO_MT_*`为前缀;Panel 的以`OMOO_PT_*`为前缀。比如`OMOO_PT_render_panel`
- Operator 自己声明的 Property 使用无项目前缀的业务名称,比如`input_path`、`resolution_level`
- 只有直接挂载到公共 Blender RNA 类型的自定义 Property 使用项目前缀,比如`Scene.omoo_settings`
- `README.md` 面向用户;实现原理放入 `docs/` 对应主题,不在 README 堆叠内部细节
- 不使用 `---` 分隔线;一级标题只在文档开头使用一次
- Mermaid 节点 ID 使用英文字符,节点显示文本可以使用中文
- 文档描述当前已实现行为;限制、默认值、命令和文件名必须能从代码或测试中验证

View File

@@ -23,6 +23,11 @@ SnapForm 是一个 Blender 扩展,可以把一张照片、一段视频或一
`BEN2 Base`;不安装 PyTorch,也不下载各模型的 Python 源码包。其他 AI 模型可在
`Models` panel 中按需下载,请保持网络畅通,首次安装需要一些时间。
安装时 Environment、MoGe-2 和 BEN2 各自使用独立的 0–100% 进度;Environment 这一轮内部依次完成 uv、Python 环境和 packages。进入后续模型时进度归零,但不显示 `2/2` 等阶段编号。模型源发生瞬时错误时会切换到另一来源继续下载。
任一必需模型在两个来源都下载失败时,`Install Environment` 会明确报错;Environment 本身仍会保留。
选中 Image Empty 后,右键 `SnapForm` 子菜单会在缺少 `MoGe-2 ViT-S Normal` 或 `BEN2 Base`
时显示 `Download Required Models`,一次补齐缺失项,不会重复下载已经就绪的模型。
必需模型补齐前,菜单中的 `Convert to Depth Plane` 会保持禁用。
这些 UI 状态直接检查 Storage Root 的 `models/` 文件,不会仅因打开菜单或面板而启动 Job Server。
## 快速上手
@@ -79,16 +84,18 @@ Session 逐帧推理,显存峰值不随视频帧数平方增长;Debug 多帧
## 常见问题
- 首次使用需要下载模型,如果下载慢或失败,稍后在 `Models` panel 里重试即可
- 从 0.12.0–0.12.2 升级到 0.12.3 后,请重新执行一次 `Install Environment`,让 Storage Root 中的 Environment 安装修复后的 BlendJob wheel
- 首次使用需要下载模型,如果下载慢或失败,可在 Image Empty 的 `SnapForm` 右键菜单点击
`Download Required Models`,也可在 `Models` panel 里逐项重试
- 安装 Environment 时 uv 下载失败会自动尝试 OmooLab 镜像;Python packages 需要指定镜像时设置
`UV_INDEX_URL` 后重新安装
- 默认模型下载会在 Hugging Face 与 OmooLab 镜像之间自动回退;某一来源暂时断线不会阻止后续模型尝试另一来源
- 模型直链建连超过 10 秒,或连接后连续 60 秒没有收到数据,会切换到下一来源;不限制总下载时长
- 找不到右键入口时,请确认选中了 Empty,且显示方式为 Image
- 动画面片不动:检查 Empty 的 `Image Frames` 是否大于 1,并确认播放场景帧
## 技术文档
AI、帧提取与后续模型下载统一提交给当前 Blender 专属的 Job Server。`runtime = BlenderJobRuntime(...)` 独占 Storage Root、Environment、Server 进程、Status Bar 和当前 Job;Python 与依赖通过 `environment` 字典声明,BlendJob 负责完整安装。JobServer 使用单 Worker FIFO Queue,忙碌时接受并顺序执行后续任务。内建 Status Bar 使用兼容 Blender 4.5 回调所有权机制的稳定函数对象。SnapForm 的 `post_install(runtime)` 使用 `runtime.request()` 下载默认模型。`server/__init__.py` 集中注册全部公开 Job;业务实现按 `media.py`、`geometry.py`、`background.py`、`moge2.py` 与 `da3.py` 直接拆分。Job-based Operator 继承 `runtime.JobOperatorBase`,直接返回参数字典,并在可选的 `response(context, result)` 中完成 Blender 后处理。模型 catalog、下载和 Session 分别由 `model_catalog.py`、`model_download.py` 与 `model_manager` Resource 管理,不存在循环 import。模型下载固定尝试 Hugging Face,再尝试 R2,两者失败即结束当前 Job。BlendJob 接受任务时在统一 Storage Root 下创建 opaque-ID 工作目录。
详细功能与技术内容见 [docs/index.md](docs/index.md)。
可复用的本地 Job Server API 与插件接入方式见 [docs/blendjob.md](docs/blendjob.md)。
Blender 侧只负责界面、任务提交与结果导入,AI 与文件输出由独立 Job Server 执行。详细功能见
[文档索引](docs/index.md),模块边界、调用流程、项目结构与开发命令见
[程序设计、开发与许可证](docs/development.md)。

View File

@@ -1,255 +0,0 @@
# BlendJob 开发者指南
## 目标与边界
`blendjob` 连接 Blender Operator 与独立 Python Job Server。开发者新增任务时只需要一个 Blender Operator 和一个 Server Handler,不需要编写提交线程、HTTP 轮询、取消、进度条、Server 进程或 Environment 安装 Operator。
- `BlenderJobRuntime`:Blender 侧唯一运行时,持有 Storage Root、Environment、Server 进程和当前 Job
- `JobOperatorBase`:绑定 Runtime 后供业务 Operator 直接继承的 Modal Mixin
- `JobServer`:独立 Python 进程中的 Job 注册、状态、取消、工作目录和 Resource
- `JobContext`:Handler 的工作目录、Storage Root、进度、取消与 Resource 接口
- `JobResult`:Server 成功结果及其 Job 文件
- `ServerController`:Runtime 内部的专属 Server 进程控制器
## 项目配置
Server 实例直接定义在项目的 `server` package:
```python
# snapform/server/__init__.py
from blendjob import JobServer
server = JobServer("SnapForm Job Server")
```
Blender 侧把这个实例交给 Runtime。实例不会跨进程传输;Runtime 从导出该实例的 package 自动找到独立 Python 入口。
```python
# snapform/job_runtime.py
from .server import server
environment = {
"python": "3.12",
"packages": [
"numpy==2.4.2",
"pillow==12.1.1",
"av==17.0.0",
"trimesh==4.11.2",
"huggingface-hub==1.4.1",
],
"platform_packages": {
"windows": ["onnxruntime-directml==1.24.4"],
"default": ["onnxruntime==1.24.4"],
},
}
def post_install(runtime):
runtime.request(
"download-model",
{"model": "MOGE2_VITS_NORMAL"},
)
runtime = BlenderJobRuntime(
server,
storage_root=storage_root,
environment=environment,
post_install=post_install,
)
JobOperatorBase = runtime.JobOperatorBase
```
Runtime 按约定管理 `.venv`、Manifest、安装状态与日志路径。BlendJob 根据 `environment` 字典安装固定 Python、通用的 FastAPI/Uvicorn Server 依赖、项目 packages 以及当前平台 packages。配置会生成稳定 Hash;Python 或依赖声明变化后 Environment 自动判定为需要重装,不再需要项目维护 `install_env.py` 或手工 Revision。
`platform_packages` 优先选择 `windows`、`macos` 或 `linux`,没有对应项时使用 `default`。基础 packages 与平台 packages 会合并安装。
`post_install` 是可选的普通 callable,在 Environment 可用且 Server 能启动后执行。它不限定为 Job 列表,可以执行任意 Python;SnapForm 用它发起默认模型下载。连续 request 会分别重置进度,Status Bar 不为未知数量的 post-install 操作显示 `2/2` 等阶段总数。post-install 失败不会撤销已经安装成功的 Environment。
`runtime.register()` 自动注册 Environment 安装、Job 取消、Server 启动、停止、重启和日志 Operator,并挂载内建 Status Bar。Status Bar 使用可由 Blender 4.5 标记所有权的稳定函数回调,Runtime 卸载时移除同一个回调对象。业务 `CLASSES` 只包含业务类型。
```python
CLASSES = (
GenerateMoGe2Depth,
)
def register():
for class_type in CLASSES:
bpy.utils.register_class(class_type)
runtime.register()
def unregister():
runtime.unregister()
for class_type in reversed(CLASSES):
bpy.utils.unregister_class(class_type)
```
启用 Runtime 不会立即启动 Server。第一次提交 Job 或显式调用 Start Server 时才启动当前 Blender 专属进程。
## Blender Operator
最小 Operator 只声明 `job_type` 与自己的 Blender Property:
```python
class GenerateMoGe2Depth(JobOperatorBase, bpy.types.Operator):
bl_idname = "snapform.generate_moge2_depth"
bl_label = "Generate MoGe-2 Depth"
job_type = "generate-moge2-depth"
input_path: bpy.props.StringProperty()
resolution_level: bpy.props.IntProperty(
default=5,
min=0,
max=9,
)
```
执行 `bpy.ops.snapform.generate_moge2_depth()` 调用的就是这个 Operator。`JobOperatorBase` 提供它的 `execute()` 和 `modal()`,不会再创建或调用隐藏的 Job Operator。
没有覆盖 `request()` 时,Operator 声明的全部 Property 会作为参数提交:
```json
{
"input_path": "D:/image.png",
"resolution_level": 5
}
```
后端可直接忽略不使用的字段。BlendJob 不提供 Property 排除表。
### request
需要读取 Blender Context 或生成临时输入时覆盖:
```python
def request(self, context):
input_path, temporary = prepare_image_input(
context.object.data,
context.scene,
)
self._temporary_input = input_path if temporary else None
return {
"input": str(input_path),
"resolution_level": self.resolution_level,
}
```
返回值是完整参数,纯替换默认 Property 参数,不做合并。它必须是可由 JSON 表示的 `dict`。
### response
Server 成功后,Runtime 在 Blender 主线程调用:
```python
def response(self, context, result):
build_moge2_depth_plane(
context,
source_object=bpy.data.objects[self._source_object_name],
npz_path=result.file("depth"),
)
```
`response()` 是可选的。未覆盖时 Job 成功即返回 `FINISHED`。开发者可以直接调用 Python 函数,也可以调用其它 Blender Operator;BlendJob 不限制后处理方式。
`cleanup()` 同样可选,在成功、失败和取消后都会执行,适合删除临时输入。
业务代码直接返回 `dict`。BlendJob 没有公开 `JobRequest` 包装类型;HTTP Payload 由 Runtime 内部构造。
## 直接 Request
不需要 Blender Operator 时,可以直接向 Runtime 发起一个同步 request:
```python
def downloaded(result):
print(result.directory)
result = runtime.request(
"download-model",
{"model": "MOGE2_VITS_NORMAL"},
response=downloaded,
)
```
`runtime.request(job_type, parameters, response=None)` 负责启动 Server、提交、轮询、失败处理与构造 `JobResult`,返回值就是 Server response。可选的 `response(result)` 在发起 request 的线程执行。它是同步 API,适合 `post_install`、后台线程或脚本;Blender 主线程中的交互功能仍应使用 `JobOperatorBase`,避免阻塞界面。
## Server Handler
Handler 直接注册为 `handler(context, parameters)`:
```python
@server.job("generate-moge2-depth")
def generate_moge2_depth(context, parameters):
model = context.resource("model_manager").get(
parameters["model"],
parameters["device"],
)
context.progress(0.1, "Loading MoGe-2")
context.check_cancelled()
depth = model.infer(
parameters["input"],
resolution_level=parameters["resolution_level"],
)
output = context.directory / "results.npz"
save_depth(output, depth)
return {"depth": output.name}
```
返回值保存在终态的 `result` 字段。Blender 端的 `JobResult.file("depth")` 会把相对路径限制在当前 Job Directory 内并验证文件存在。
`JobContext` 提供:
- `storage_root`:Runtime 传入的唯一共享根目录
- `directory`:`storage_root/jobs/<job-id>`
- `progress(value, message, **details)`:发布运行进度
- `check_cancelled()`:在安全检查点响应取消
- `resource(name)`:访问 Server 生命周期 Resource
Handler 不需要也不应管理 `queued`、`succeeded`、`failed` 等状态;这些状态由 `JobServer` 内部更新,因此公开 API 不再同时提供语义重复的 `update()`。Environment 安装发生在 Server 启动前,仍通过 Runtime 的 Bootstrap 状态文件回传进度,不属于 `JobContext`。
## Storage Root
Runtime 是 Storage Root 的唯一配置者。`JobServer` 不需要在项目代码中声明 `jobs_directory`,启动后固定使用:
```text
<storage_root>/
├── .venv/
├── environment.json
├── jobs/
├── models/
├── tools/
├── environment.log
├── environment-status.json
├── server.log
└── manifest.json
```
需要 Storage Root 的 Resource 使用延迟工厂:
```python
@server.resource("model_manager")
def create_model_manager(job_server):
return ModelManager(job_server.storage_root / "models")
```
Resource 在独立进程绑定 Storage Root 后创建,关闭 Server 时按注册逆序关闭。
模型等领域数据应由 Server Resource 管理。Blender 可以通过 `runtime.resource("model_manager")` 查询 Resource Snapshot,不应自行拼接模型目录或检查模型文件。
## Server 生命周期
每个 Blender 实例为当前插件持有一个专属 Server。Runtime 自动处理随机端口、Instance ID、父 Blender PID、启动日志、连接验证、重启和停止。
Server 只监听 `127.0.0.1`。当前本地专属进程协议不使用一次性 Token,不发现或复用其它 Blender 启动的 Server。
`JobServer` 内建一个标准库单 Worker FIFO Queue。新 Job 会立即得到 ID 和目录,空闲时执行,忙碌时保持 `queued`;同一时刻只运行一个 Handler。排队任务可以取消,调用方不需要自己等待 Server Idle,`runtime.request()` 也不使用额外的 Idle 轮询补丁。
模型和 Environment 可以共享同一 Storage Root;共享磁盘目录不表示共享 Server 进程。多个进程可能同时下载同一模型时,业务层仍需要文件锁。

View File

@@ -23,7 +23,7 @@
Image Empty 引用视频或序列帧时,DA3 与 MoGe-2 会按 Empty 的 Image Frames 数值逐帧估计
深度(未设置时为完整帧数,显式设为 1 时只处理 1 帧),起始帧跟随 Empty 的 Offset,与背景蒙版范围一致;
DA3 会复用同一个 ONNX Session 对每帧独立推理,再合并为原有序列输出,避免多帧 Attention 的平方级显存占用。
该行为只用于 Depth Plane 与 Surface 转换;Debug 多帧输出仍联合推理。
该行为只用于 Depth Plane 转换;Surface 仅支持单张图片,Debug 多帧输出仍联合推理。
Offset 超出视频长度时同样把窗口收进视频内。材质节点与置换贴图的 Start 保持与原 Empty
一致;Original 模式的视频或序列帧使用 `frame.0001.jpg` 等原图序列作为颜色与深度,
`foreground.0001.png` 只提供前景 Alpha。

View File

@@ -1,57 +1,115 @@
# 开发与许可证
# 程序设计、开发与许可证
## 开发
## 程序设计
```bash
uv run pytest tests -v
uv run python pack.py
SnapForm 分成 Blender 主进程与独立 Job Server 两侧。Blender 侧只负责界面、任务参数和 `.blend` 数据修改;模型下载、媒体准备、ONNX 推理和文件输出都在 Python 3.12 Environment 中运行。两侧由随扩展分发的 BlendJob wheel 连接,避免把 FastAPI、ONNX Runtime 等计算依赖载入 Blender Python。
```mermaid
flowchart LR
User["Image Empty / Debug Panel"] --> Operator["Blender Operator"]
Operator --> Runtime["JobRuntime"]
Runtime --> Server["JobServer FIFO Queue"]
Server --> Handler["SnapForm Job Handler"]
Handler --> Model["ModelManager / ONNX"]
Handler --> JobFiles["Job Directory"]
JobFiles --> Response["Operator response"]
Response --> Scene["Blender Scene"]
```
项目结构:
核心边界如下:
- `src/snapform/__init__.py` 是注册入口,集中注册 Blender 类型、`Scene.snapform_settings`、右键菜单与 Runtime
- `panel.py`、`preferences.py`、`properties.py` 只描述 UI 和 Blender 属性,不直接导入 Server 模型实现
- `operators.py` 将 Blender 状态转换为普通字典参数;Job 完成后在主线程导入图片、网格、材质和相机
- `blender/depth_plane.py` 与 `blender/surface.py` 实现 Image Empty 输入检查、几何构建和资产挂载
- `job_runtime.py` 以入口字符串声明独立 Server,并配置 Environment、Storage Root、BlendJob wheel 与首次安装后的推荐模型下载
- `server/__init__.py` 创建 `JobServer`、注册公开 Job,并把 `ModelManager` 注册为 Resource
- `server/media.py`、`background.py`、`da3.py`、`moge2.py` 负责业务流程;`onnx_*.py` 只处理模型输入、推理和输出恢复
- 根目录 `model_catalog.py` 是 Blender UI 与独立 Server 共用的纯数据声明;`server/model_download.py`、`server/model_manager.py` 分别负责下载和 Session 生命周期
- `server/geometry.py` 生成 NPZ、预览、点云和纹理网格;所有任务产物写入 Runtime 分配的 opaque-ID Job 目录
功能细节继续按主题维护:[Depth Plane](depth-plane.md)、[Surface](surface.md)、[输出格式](outputs.md) 和 [Job Server](server.md)。
## 项目结构
```text
.
├── AGENTS.md
├── README.md
├── pack.py
├── sync_models.py
├── docs/
├── src/
│ └── snapform/
│ ├── __init__.py
│ ├── blender_manifest.toml
│ ├── job_runtime.py
│ ├── operators.py
│ ├── panel.py
│ ├── preferences.py
│ ├── properties.py
│ ├── job_runtime.py
│ ├── blendjob/
│ │ ├── client.py
│ │ ├── controller.py
│ │ ├── environment.py
│ │ ├── installer/environment.py
│ │ ├── operator.py
│ │ ├── runner.py
│ │ ├── runtime.py
│ │ └── server.py
│ ├── assets/
│ │ └── O_SnapForm.blend
│ ├── blender/
│ │ ├── depth_plane.py
│ │ └── surface.py
│ └── server/
│ ├── __init__.py
│ ├── model_catalog.py
│ ├── model_download.py
│ ├── model_manager.py
│ ├── media.py
│ ├── geometry.py
│ ├── background.py
│ ├── moge2.py
│ ├── da3.py
│ ├── onnx_ben2.py
│ ├── onnx_da3.py
│ ├── onnx_moge2.py
│ └── onnx_runtime.py
└── tests/
│ ├── model_catalog.py
│ ├── server/
│ │ ├── __init__.py
│ │ ├── background.py
│ │ ├── da3.py
│ │ ├── geometry.py
│ │ ├── media.py
│ │ ├── model_download.py
│ │ ├── model_manager.py
│ │ ├── moge2.py
│ │ ├── onnx_ben2.py
│ │ ├── onnx_da3.py
│ │ ├── onnx_moge2.py
│ │ └── onnx_runtime.py
│ └── wheels/
│ └── blendjob-0.1.5-py3-none-any.whl
├── tests/
└── tools/
```
## 常见命令
在项目根目录使用 Git Bash:
```bash
uv sync
uv run pytest
uv run pytest tests/test_addon.py tests/test_manifest.py tests/test_pack.py -v
uv run python pack.py
```
`pack.py` 根据 `blender_manifest.toml` 分别生成 Windows、macOS 和 Linux 扩展包,例如 `dist/SnapForm.v0.12.3.windows-x64.zip`。更改 `src/snapform/wheels/` 中的本地 wheel 后,先刷新锁文件再测试:
```bash
uv lock --refresh
uv run pytest
```
## 资产构建与验证
`tools/` 脚本依赖 `bpy`,必须由 Blender 执行。设置本机 Blender 可执行文件后,可以重建或验证 `O_SnapForm.blend`:
```bash
BLENDER_BIN="/c/Program Files/Blender Foundation/Blender 4.5/blender.exe"
"$BLENDER_BIN" --background --python tools/build_geometry_nodes_asset.py
"$BLENDER_BIN" --background --python tools/validate_depth_plane_asset.py
"$BLENDER_BIN" --background --python tools/validate_geometry_nodes_asset.py
"$BLENDER_BIN" --background --python tools/validate_moge_depth_plane.py
"$BLENDER_BIN" --background --python tools/validate_moge_surface.py
```
只改 Python、文档或打包逻辑时不需要重建 `.blend` 资产;修改节点组、材质组或其接口时,应运行相应资产验证脚本,并再次运行完整测试。
## 上游与许可证
- Depth Anything 3 代码采用 Apache 2.0。
- BEN2 ONNX 模型采用其仓库声明的 Apache 2.0 License。
- 本 Extension 代码采用 GPL-3.0-or-later。
- 模型权重许可证由所选 Hugging Face 模型仓库决定,Extension 下载前会在界面显示。
- Depth Anything 3 代码采用 Apache 2.0
- BEN2 ONNX 模型采用其仓库声明的 Apache 2.0 License
- SnapForm Extension 代码采用 GPL-3.0-or-later
- 模型权重许可证由所选 Hugging Face 模型仓库决定,Extension 下载前会在界面显示

View File

@@ -36,6 +36,10 @@
组成三角面并写入 `mesh.glb`;每个输入帧生成一张独立纹理网格,不做多视图融合
- Image Empty 右键菜单可直接运行 DA3,根据 `mini_npz` 深度生成面片
- Image Empty 右键菜单可直接运行 BEN2,保留原图并换用新建的透明背景图片
- 缺少必需的 `MoGe-2 ViT-S Normal` 或 `BEN2 Base` 时,Image Empty 的 `SnapForm`
右键子菜单显示 `Download Required Models`;单次任务只下载缺失项,并在补齐前禁用
`Convert to Depth Plane`
- 菜单、模型面板和转换对话框直接检查 Storage Root 的 `models/` 必需文件,显示模型状态时不会启动 Job Server
- Image Empty 支持视频与序列帧输入:`Convert to Depth Plane` 逐帧估计深度,
`Remove Background` 逐帧抠图,入口保持不变。DA3 转换时每帧独立执行 ONNX 推理并复用模型 Session;
Debug 多帧任务继续联合推理,以保留跨视图相机与深度关系
@@ -51,14 +55,14 @@
## 使用
1. 运行 `uv run python pack.py`,分别构建 `windows-x64`、`macos-arm64`、
`linux-x64` 三个平台的 Extension ZIP(如 `SnapForm.v0.11.2.macos-arm64.zip`)。
`linux-x64` 三个平台的 Extension ZIP(如 `SnapForm.v0.12.3.macos-arm64.zip`)。
安装包不携带 uv 或 AI 项目源码;首次安装 Environment 时下载固定版本 uv,官方源建连超过 3 秒后回退 OmooLab 镜像,并校验固定 SHA-256。
2. 在 Blender 的 `Preferences > Extensions` 中选择 `Install from Disk`。
3. 在 Extension Preferences 中确认 Storage Root 与 Device。Device 是正式功能
(Image Empty 右键菜单)使用的推理设备,默认取本机最快的 DirectML/CoreML provider,否则 CPU,且只列出
本机支持的设备。
4. 在 `Job Server` 点击 `Install Environment`。首次安装会下载 Python、ONNX Runtime、FastAPI、Uvicorn;
uv 由 BlendJob Environment 安装器下载并校验,DA3、BEN2 与 MoGe 不安装源码包,
uv 由 Job Runtime 的 Environment 安装器下载并校验,DA3、BEN2 与 MoGe 不安装源码包,
并在 Environment 安装后自动下载 `MoGe-2 ViT-S Normal` 和 FP16 `BEN2 Base` 推荐模型。首次安装
依次显示 `Environment`、`MoGe-2 ViT-S Normal` 与 `BEN2 Base`;每项都独立从 0 走到 100%,
进入下一项时进度条归零,模型 post-install 不显示 `2/2` 等阶段编号。uv、Python Environment 与 packages 合并在第一轮,
@@ -68,10 +72,12 @@
`Downloading DA3 Small 14.2 MB/s`;镜像来源显示为
`Downloading DA3 Small (mirror) 14.2 MB/s`。下载按字节连续推进,重试或切换镜像时进度不会倒退。
Environment 安装阶段与 `Models` 面板单独下载使用相同的模型名称格式,不会改写为通用的 `Downloading packages`。
Environment 安装后即使模型下载中断也不影响 Server 使用,可稍后在 `Models` 面板单独补下。
安装不做启动网络预检;每个模型都先尝试 Hugging Face,失败后尝试 OmooLab R2,两者都失败时当前 Job 失败。
下载源不写入持久状态;需要手动指定依赖镜像时可通过
`UV_INDEX_URL` 环境变量覆盖。
Environment 安装后即使模型下载中断也仍会保留,但 `Install Environment` 会以失败结束并显示模型下载错误。
可稍后在 Image Empty 的 `SnapForm` 右键菜单用 `Download Required Models` 一次补齐缺失的
MoGe-2 ViT-S 与 BEN2,也可在 `Models` 面板单独补下。
安装不做启动网络预检;每个模型都先尝试 Hugging Face,失败后尝试 OmooLab R2,两者都失败时当前 Job 失败。
下载源不写入持久状态;需要手动指定依赖镜像时可通过
`UV_INDEX_URL` 环境变量覆盖。
5. 在 3D View 按 `N`,打开 `SnapForm` 页签。
6. 如需其他模型,展开 `Models` panel,找到模型并点击该行的 `Download`。
7. 展开默认收起的 `Debug`,选择 `Output Type` 和本次执行使用的模型。

View File

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

View File

@@ -2,7 +2,7 @@
## 运行架构
SnapForm 在独立的 Python 3.12 Environment 中运行 HTTP Job Server,Blender 只通过标准库 HTTP Client 提交和轮询任务,不把计算依赖载入 Blender Python。Server 只监听 `127.0.0.1`,由 BlendJob `ServerController` 保存随机端口与 Instance ID;专属本地进程协议不使用额外 Token 鉴权。
SnapForm 在独立的 Python 3.12 Environment 中运行 HTTP Job Server,Blender 只通过标准库 HTTP Client 提交和轮询任务,不把计算依赖载入 Blender Python。Server 只监听 `127.0.0.1`,由外置 BlendJob wheel 提供的 `ServerController` 保存随机端口与 Instance ID;专属本地进程协议不使用额外 Token 鉴权。
每个 Blender 实例启动并持有一个 SnapForm 专属 Server,不发现或复用其它 Blender、其它插件启动的本地 Server。多个 Blender 可以对应多个进程,但模型、Environment 与任务产物仍可共享同一个 Storage Root。
@@ -15,27 +15,28 @@ Environment、模型和任务结果默认保存在 `~/.snapform`:
├── models/
├── jobs/
├── manifest.json
├── environment.log
├── environment-status.json
├── install.log
├── install-status.json # 仅在安装期间存在
└── server.log
```
不同 Blender 版本可共享此目录。也可以在 Extension Preferences 的 `Storage Root` 中修改位置。连接端口与 Instance ID 只保存在启动它们的 Blender 进程内存中。
通用 HTTP 层位于 `blendjob` module。`JobServer` 包装 FastAPI 并提供 Job、FIFO Queue 与 Resource API,`JobClient` 是无第三方依赖的 Blender 侧访问器。SnapForm 的 `server/__init__.py` 直接导出 `server` 实例、集中注册全部公开 Job,并把 `ModelManager` 注册为延迟创建的 `model_manager` Resource。完整接入方式见 [BlendJob 开发者指南](blendjob.md)。
通用 HTTP 层由 `src/snapform/wheels/` 中的 BlendJob package 提供。`JobServer` 包装 FastAPI 并提供 Job、FIFO Queue 与 Resource API,`JobClient` 是无第三方依赖的 Blender 侧访问器。Blender 侧只把 `server/__init__.py:server` 入口字符串交给 Runtime,不导入 `server` package;独立进程启动后才取得其中的 `server` 实例、注册全部公开 Job,并创建 `model_manager` Resource。
SnapForm 根 package 不再有 `client.py`、`environment.py`、`storage.py` 或 `models.py`。这些文件会制造并不存在的业务边界:
- Storage Root、`.venv`、Manifest、安装状态与日志路径由 `BlenderJobRuntime` 唯一管理;
- Storage Root、`.venv`、Manifest、安装状态与日志路径由 `JobRuntime` 唯一管理;
- `InstallEnvironment` 由 `runtime.operator_classes()` 提供,Python 与 packages 直接声明在传给 Runtime 的 `environment` 字典中;
- 模型 catalog、磁盘目录、下载和 ONNX Session 都属于 Server,分别位于 `model_catalog.py`、`model_download.py` 与 `model_manager.py`;
- Blender UI 只读取 `model_manager` Resource Snapshot,不导入模型 catalog,也不检查模型文件。
- 模型 catalog 由 `model_catalog.py` 统一声明;下载和 ONNX Session 属于 Server,分别位于 `model_download.py` 与 `model_manager.py`;
- Blender UI 复用同一 catalog,直接检查 Storage Root 下的模型必需文件,不为显示下载状态启动 Server;已加载 Session 状态仍读取现有 Server Snapshot。
`blendjob/client.py` 仍然存在,但它只是 BlendJob 内部的无依赖 HTTP transport,不是 SnapForm 业务抽象,也不向 Operator 暴露 `JobRequest`。
BlendJob 的 `JobClient` 只是无依赖 HTTP transport,不是 SnapForm 业务抽象,也不向 Operator 暴露 `JobRequest`。
SnapForm 的业务 Operator 直接提交参数字典,Job Type 一一对应:
- `download_model` → `download-model`
- `download_required_models` → `download-required-models`
- `remove_background` → `remove-background`
- `extract_frames` → `extract-frames`
- `generate_da3_depth` → `generate-da3-depth`
@@ -62,7 +63,7 @@ Server 端没有 `execute_*`、`*_result` 或 CLI worker 中转。全部 `@serve
Windows 当前发行环境提供 DirectML 与 CPU;`Auto` 优先使用 `DmlExecutionProvider`。显式指定不可用设备时会安全回退到 CPU。其他平台安装标准 ONNX Runtime,并按可用 provider 选择 CoreML、OpenVINO 或 CPU。
`environment.json` 保存规范化后的声明,`manifest.json` 保存该声明与 BlendJob 基础 Server packages 的 Hash。Python、packages、平台 packages 或 BlendJob Environment Schema 改变后需要重新执行 `Install Environment`,不再维护手工 Manifest Revision。
`environment.json` 保存规范化后的声明,`manifest.json` 保存该声明、BlendJob wheel 与基础 Server packages 的 Hash。Python、packages、平台 packages 或 Environment Schema 改变后需要重新执行 `Install Environment`,不再维护手工 Manifest Revision。
## 模型
@@ -72,11 +73,16 @@ Windows 当前发行环境提供 DirectML 与 CPU;`Auto` 优先使用 `DmlExec
- DA3 Small、DA3 Base
- BEN2 Base
首次安装在 `post_install(runtime)` 中自动下载 `MoGe-2 ViT-S Normal` 与 FP16 `BEN2 Base`。它们和 `Models` panel 的后续下载一样,都是 `download-model` Job,与推理共用队列、HTTP 进度和取消协议。模型按声明的目录名保存在 `~/.snapform/models/`,并保留仓库内部结构。声明了文件清单与校验值的模型先走 Hugging Face `resolve` 直链流式下载,失败后尝试 `https://models.omoolab.xyz/<repository-name>/<relative-path>`;两者都失败时当前 Job 直接失败,不记录或猜测后续下载源。建连成功后不限制模型文件读取时长。R2 文件会校验大小与 SHA-256。未声明清单的仓库仍使用 `huggingface_hub` 快照下载。可用 `HF_ENDPOINT` 指定 Hugging Face 镜像。
首次安装在 `post_install(runtime)` 中自动下载 `MoGe-2 ViT-S Normal` 与 FP16 `BEN2 Base`。它们和 `Models` panel 的后续下载一样,都是 `download-model` Job,与推理共用队列、HTTP 进度和取消协议。模型按声明的目录名保存在 `~/.snapform/models/`,并保留仓库内部结构。声明了文件清单与校验值的模型先走 Hugging Face `resolve` 直链流式下载,失败后尝试 `https://models.omoolab.xyz/<repository-name>/<relative-path>`;两者都失败时当前 Job 直接失败,不记录或猜测后续下载源。模型直链的 TCP、代理 CONNECT 与 TLS 建连最长等待 10 秒;连接后连续 60 秒没有收到响应数据则视为当前来源失败并切换下一来源。该空闲超时不限制模型总下载时长。R2 文件会校验大小与 SHA-256。未声明清单的仓库仍使用 `huggingface_hub` 快照下载。可用 `HF_ENDPOINT` 指定 Hugging Face 镜像。
`model_manager` Resource 对外统一表示模型系统,持有模型 catalog、磁盘就绪状态和内存 Session。Blender 不拼接模型目录,也不直接检查权重文件。`ModelSessionCache` 保留 BEN2,并在 DA3 与 MoGe-2 之间只保留最近使用的一种 Depth Session。`GET /resources/model_manager` 返回 catalog、下载状态和当前加载状态;`POST /resources/model_manager/clear` 只允许在没有 Active 或 Queued Job 时执行。
Image Empty 的右键菜单会读取 `model_manager` 状态;缺少 MoGe-2 ViT-S 或 BEN2 时显示
`Download Required Models`。对应的 `download-required-models` Job 在 Server 端再次检查就绪状态,
只下载缺失项,并把多个下载的进度合并为单次 0–100%。必需模型补齐前,右键菜单中的
`Convert to Depth Plane` 保持禁用。
DA3 ONNX 使用模型的 camera decoder 恢复外参与内参;旧版 `Use Ray Pose` 参数只为任务协议兼容保留,不参与 ONNX 推理。MoGe-2 的 metric depth、normal、mask、normalized intrinsics,以及 BEN2 的 Alpha,均在 NumPy 后处理中恢复。
`model_manager` Resource 在 Server 内持有模型 catalog、磁盘就绪状态和内存 Session。Blender UI 使用 catalog 声明的目录名与 `ready_patterns` 直接检查 `<storage-root>/models/`,因此展开模型面板、打开转换对话框或 Image Empty 右键菜单都不会仅为状态检测启动 Server。真正下载或推理时,Server 仍通过 `ModelManager.ready()` 重新验证文件。`ModelSessionCache` 保留 BEN2,并在 DA3 与 MoGe-2 之间只保留最近使用的一种 Depth Session。`GET /resources/model_manager` 继续返回 catalog、下载状态和当前加载状态;`POST /resources/model_manager/clear` 只允许在没有 Active 或 Queued Job 时执行。
DA3 ONNX 使用模型的 camera decoder 恢复外参与内参,不提供 ray-pose 参数。MoGe-2 的 metric depth、normal、mask、normalized intrinsics,以及 BEN2 的 Alpha,均在 NumPy 后处理中恢复。
## 安装网络与镜像
@@ -85,7 +91,9 @@ DA3 ONNX 使用模型的 camera decoder 恢复外参与内参;旧版 `Use Ray
uv 默认从 Astral 的 GitHub Release 下载,失败后回退到 `https://mirror.omoolab.xyz/uv/<version>/`。两个来源使用相同的连续字节进度并共用固定 SHA-256 校验。下载压缩包只作临时文件,提取完成后删除。模型下载不经过 GitHub。
首次安装依次显示 Environment、MoGe-2 下载与 BEN2 下载,每项分别使用 0–100%。Environment 由 BlendJob 依次完成 uv 下载、Python Environment 创建与 packages 安装,uv 不再占用独立一轮进度。两个模型下载由 SnapForm 的 `post_install(runtime)` 通过 `runtime.request()` 顺序发起;切换模型时进度归零,但不显示 `2/2` 等阶段编号。
首次安装依次显示 Environment、MoGe-2 下载与 BEN2 下载,每项分别使用 0–100%。Environment 由 Job Runtime 依次完成 uv 下载、Python Environment 创建与 packages 安装,uv 不再占用独立一轮进度。两个模型下载由 SnapForm 的 `post_install(runtime)` 通过 `runtime.request()` 顺序发起;切换模型时进度归零,但不显示 `2/2` 等阶段编号。
任一 post-install 模型失败时,Environment 文件仍然保留,但安装 Operator 返回失败并显示聚合后的模型错误,
不再把带 `post_install_error` 的终态报告为成功。
## 生命周期
@@ -93,19 +101,19 @@ uv 默认从 Astral 的 GitHub Release 下载,失败后回退到 `https://mirr
AI、帧提取和 Models panel 下载的 Job 进度保存在 Server 内存中。Handler 只使用 `context.progress(value, message)` 发布进度,终态由 JobServer 内部管理;SnapForm 不再维护独立的 `server/progress.py` 或 Job 状态 JSON。Blender 每 0.25 秒轮询 `GET /jobs/{job_id}`,任务目录不写 `status.json`。Operator 直接提交参数字典;Job 被接受时由 `JobServer` 自动生成 ID 并创建 `jobs/<opaque-job-id>/`,提交响应同时返回 `job_id` 与 `directory`。路径通过 `JobContext.directory` 传给 Handler,SnapForm Server 强制将输出写入该目录。
`job_runtime.py` 创建唯一的 `runtime = BlenderJobRuntime(...)`,直接接收 `server` 实例并导出 `runtime.JobOperatorBase`。Runtime 独占 Storage Root、Environment 路径、安装进程、Server 控制和 Status Bar。Operator 未覆盖 `request(context)` 时会提交全部 Property;覆盖后返回值纯替换默认参数。可选的 `response(context, result)` 在 Blender 主线程直接完成结果导入。SnapForm 不再提供 Client 请求层、Job Mixin、Status Bar 或 Build 中转 Operator。
`job_runtime.py` 创建唯一的 `runtime = JobRuntime(...)`,以自身目录为 `entrypoint_root`,传入 `server:server` 相对入口字符串与显式 `snapform` namespace,并导出 `runtime.JobOperatorBase`。Runtime 独占 Storage Root、Environment 路径、安装进程、Server 控制和 Status Bar。Operator 未覆盖 `request(context)` 时会提交全部 Property;覆盖后返回值纯替换默认参数。可选的 `response(context, result)` 在 Blender 主线程直接完成结果导入。SnapForm 不再提供 Client 请求层、Job Mixin、Status Bar 或 Build 中转 Operator。
Environment 安装是 Server 启动前唯一的 Bootstrap 流程,使用 Storage Root 下共享的 `environment-status.json` 和 `environment.log` 回传进度,不创建 Job 目录。Environment 成功后执行普通的 `post_install(runtime)`;默认模型下载此时已经是正式 Server Job,会创建自己的 Job 目录,并使用与其它任务相同的进度和取消协议。
Environment 安装是 Server 启动前唯一的 Bootstrap 流程:`environment.json` 记录期望安装的环境声明,`install-status.json` 在安装期间回传进度并在任务收尾后删除,`install.log` 持久记录安装输出。Bootstrap 不创建 Job 目录。Environment 成功后执行普通的 `post_install(runtime)`;默认模型下载此时已经是正式 Server Job,会创建自己的 Job 目录,并使用与其它任务相同的进度和取消协议。
Server 使用标准库单 Worker FIFO Queue,全局同时只执行一个 Job,包括模型下载。Server 忙碌时新 Job 进入 `queued`,不再返回 409;排队任务可以在执行前取消。取消运行中任务不会重启 Server;流式模型下载会在分块检查点结束,ONNX Runtime 当前调用则在返回后的最近检查点结束。JobServer 最多保留 100 个已完成 Job 的内存状态,后续提交时清理更早记录。
启动与崩溃信息写入 `~/.snapform/server.log`。
Server 由 Environment 通过 `python -m blendjob.runner` 启动,避免 BlendJob 内部模块名遮蔽 Python 标准库。启动与崩溃信息写入 `~/.snapform/server.log`。
## 已知限制
DA3 ONNX 的注意力是朴素实现(`MatMul → Softmax → MatMul`),峰值显存与 `(帧数 × 每帧 patch 数)²` 成正比。迁移到 ONNX 前的 PyTorch 版使用 `scaled_dot_product_attention`(`XFORMERS_DISABLED=1`),按块计算注意力分数、不落地完整注意力矩阵,显存近似线性,因此视频能处理几十上百帧;ONNX 导出把 SDPA 回退成朴素注意力,多帧在中高分辨率下会显存爆炸(约 12 帧 @ 1008 即需 ~90 GB)。
Depth Plane 与 Surface 的视频、序列帧转换会复用同一个 ONNX Session 逐帧独立推理,再把结果合并到现有序列输出,因此峰值显存只取决于单帧分辨率。逐帧结果不保留 DA3 的跨视图尺度与相机位姿一致性,适用于当前逐帧动画结构。Debug 多帧输出仍使用联合推理,以保留多视图关系。
Depth Plane 的视频、序列帧转换会复用同一个 ONNX Session 逐帧独立推理,再把结果合并到现有序列输出,因此峰值显存只取决于单帧分辨率。逐帧结果不保留 DA3 的跨视图尺度与相机位姿一致性,适用于当前逐帧动画结构。Surface 只支持单张图片;Debug 多帧输出仍使用联合推理,以保留多视图关系。
联合推理时,`onnx_da3.infer` 会在执行前按 `views × patches` 预估注意力张量,超过 4 GiB 直接报错并提示减少帧数或 Resolution Scale,避免 onnxruntime 报出难以定位的分配失败或 `UnicodeDecodeError`(DirectML 在中文 Windows 下报错信息为 GBK 编码,被 UTF-8 解码掩盖)。

View File

@@ -166,9 +166,9 @@ Image Empty 使用 Packed Image 时,SnapForm 会通过 Blender 写出临时 PN
`DA3 Nested Giant Large 1.1`。Small 和 Base 没有 Gaussian head,无法由
DA3 生成 `gs_ply`。
每个 Server Job 被接受时,BlendJob 在 Runtime Storage Root 的 `jobs/` 下创建一个独立文件夹。文件夹名是 32 位 opaque Job ID,不包含任务类型或对象名称。执行逻辑从 `JobContext.directory` 获取路径,输出产物直接写入该目录。
每个 Server Job 被接受时,Job Runtime 在 Runtime Storage Root 的 `jobs/` 下创建一个独立文件夹。文件夹名是 32 位 opaque Job ID,不包含任务类型或对象名称。执行逻辑从 `JobContext.directory` 获取路径,输出产物直接写入该目录。
Blender 为 Packed Image 或序列帧生成的输入副本先保存在系统临时目录,Server 读取完成后由 Blender 清理,不归档到 Job 目录。普通磁盘文件和视频仍直接读取原文件。Server 日志统一写入 `server.log`,进度只保存在 Server 内存中。Environment Bootstrap 使用 Storage Root 下的 `environment.log` 与 `environment-status.json`,不创建 Job 目录。
Blender 为 Packed Image 或序列帧生成的输入副本先保存在系统临时目录,Server 读取完成后由 Blender 清理,不归档到 Job 目录。普通磁盘文件和视频仍直接读取原文件。Server 日志统一写入 `server.log`,进度只保存在 Server 内存中。Environment Bootstrap 使用 Storage Root 下的 `install.log` 与临时的 `install-status.json`,不创建 Job 目录。
与几何输出相关的 Job Type 为 `generate-da3-depth`、`generate-moge2-depth`、`generate-da3-surface`、`generate-moge2-surface`、`debug-da3` 与 `debug-moge2`。每种 Job 只接收对应模型与输出所需的参数。

View File

@@ -1,14 +1,17 @@
[project]
name = "snapform"
version = "0.11.2"
version = "0.12.3"
description = "Fast image-guided structure building tools for Blender"
readme = "README.md"
requires-python = ">=3.11"
license = "GPL-3.0-or-later"
dependencies = []
dependencies = ["blendjob"]
[dependency-groups]
dev = ["numpy", "pytest"]
dev = ["numpy", "pillow", "pytest"]
[tool.uv]
package = false
[tool.uv.sources]
blendjob = { path = "src/snapform/wheels/blendjob-0.1.5-py3-none-any.whl" }

View File

@@ -2028,11 +2028,6 @@ class ConvertToDepthPlane(bpy.types.Operator):
items=image_depth_model_items,
default=0,
)
use_ray_pose: bpy.props.BoolProperty(
name="Use Ray Pose",
description="Legacy task compatibility; ONNX uses the camera decoder",
default=False,
)
process_res_factor: process_resolution_scale_property()
resolution_level: bpy.props.IntProperty(
name="Resolution Level",
@@ -2152,7 +2147,6 @@ class ConvertToDepthPlane(bpy.types.Operator):
source_object_name=source_object.name,
depth_data_name=depth_data_name(source_object),
model=self.model,
use_ray_pose=self.use_ray_pose,
process_res=process_res,
delete_source=not self.keep_original,
delete_input=delete_input,
@@ -2172,6 +2166,7 @@ class SnapFormImageMenu(bpy.types.Menu):
def draw(self, _context):
from ..job_runtime import runtime
from ..properties import required_models_ready
layout = self.layout
layout.operator_context = "INVOKE_DEFAULT"
@@ -2182,7 +2177,17 @@ class SnapFormImageMenu(bpy.types.Menu):
icon="IMPORT",
)
return
layout.operator(
models_ready = required_models_ready()
if not models_ready:
layout.operator(
"snapform.download_required_models",
text="Download Required Models",
icon="IMPORT",
)
layout.separator()
depth_plane_row = layout.row()
depth_plane_row.enabled = models_ready
depth_plane_row.operator(
"snapform.convert_to_depth_plane",
text="Convert to Depth Plane",
icon="MOD_DISPLACE",

View File

@@ -566,11 +566,6 @@ class ConvertToSurface(bpy.types.Operator):
items=image_surface_model_items,
default=0,
)
use_ray_pose: bpy.props.BoolProperty(
name="Use Ray Pose",
description="Legacy task compatibility; ONNX uses the camera decoder",
default=False,
)
process_res_factor: process_resolution_scale_property()
resolution_level: bpy.props.IntProperty(
name="Resolution Level",
@@ -670,7 +665,6 @@ class ConvertToSurface(bpy.types.Operator):
else:
result = bpy.ops.snapform.generate_da3_surface(
**common,
use_ray_pose=self.use_ray_pose,
process_res=process_res,
)
if "CANCELLED" in result:

View File

@@ -1,7 +1,7 @@
schema_version = "1.0.0"
id = "snapform"
version = "0.11.2"
version = "0.12.3"
name = "SnapForm"
tagline = "Build spatial structures from images, fast"
maintainer = "OmooLab"
@@ -10,6 +10,7 @@ type = "add-on"
blender_version_min = "4.5.0"
platforms = ["windows-x64", "macos-arm64", "linux-x64"]
license = ["SPDX:GPL-3.0-or-later"]
wheels = ["./wheels/blendjob-0.1.5-py3-none-any.whl"]
[permissions]
network = "Access the local Job Server and download environment dependencies and model weights"

View File

@@ -1,21 +0,0 @@
"""Reusable local HTTP Job Server for Blender add-ons."""
from .client import JobClient, JobResult
from .controller import ServerConnection, ServerController
from .operator import JobOperatorBase, JobOperatorState
from .runtime import BlenderJobRuntime
from .server import JobCancelled, JobContext, JobServer
__all__ = (
"JobCancelled",
"JobClient",
"JobContext",
"JobResult",
"JobServer",
"BlenderJobRuntime",
"JobOperatorBase",
"JobOperatorState",
"ServerConnection",
"ServerController",
)

View File

@@ -1,101 +0,0 @@
import json
from dataclasses import dataclass
from pathlib import Path
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": str(job_type), "parameters": dict(parameters)},
)
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)
class JobResult:
"""Successful Job value and files returned to a Blender Operator."""
job_id: str
directory: Path
value: object
status: dict
@classmethod
def from_status(cls, status):
return cls(
job_id=str(status.get("job_id", "")),
directory=Path(status["directory"]),
value=status.get("result"),
status=dict(status),
)
def file(self, name):
if not isinstance(self.value, dict):
raise RuntimeError("Job result does not contain named files")
try:
relative = self.value[name]
except KeyError:
raise RuntimeError(f"Job result does not contain file: {name}") from None
root = self.directory.resolve()
path = (root / str(relative)).resolve()
if path != root and root not in path.parents:
raise RuntimeError(f"Job result file escapes its directory: {relative}")
if not path.is_file():
raise RuntimeError(f"Job result file does not exist: {path}")
return path

View File

@@ -1,251 +0,0 @@
import socket
import subprocess
import time
import uuid
from dataclasses import dataclass
from .client import JobClient, JobResult
@dataclass
class ServerConnection:
port: int
pid: int
instance_id: str
client: JobClient
class ServerController:
"""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 = ServerConnection(
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("Server is not running")
health = self.connection.client.request("GET", "/health", timeout=timeout)
if health.get("instance_id") != self.connection.instance_id:
raise RuntimeError("Server 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"Server 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, job_type, parameters):
return self.ensure().client.submit(job_type, parameters)
def request(
self,
job_type,
parameters,
*,
response=None,
progress=None,
cancel_check=None,
poll_interval=0.25,
):
"""Submit one Job and return its response after polling to completion."""
submitted = self.submit(job_type, parameters)
job_id = submitted["job_id"]
directory = submitted["directory"]
cancel_requested = False
try:
while True:
if (
not cancel_requested
and cancel_check is not None
and cancel_check()
):
self.cancel(job_id)
cancel_requested = True
status = self.status(job_id)
if progress is not None:
progress({"job_id": job_id, "directory": directory, **status})
state = status.get("state")
if state == "succeeded":
result = JobResult.from_status(
{"job_id": job_id, "directory": directory, **status}
)
if response is not None:
response(result)
return result
if state == "cancelled":
raise RuntimeError("Server request was cancelled")
if state == "failed":
raise RuntimeError(status.get("error") or "Server request failed")
time.sleep(max(float(poll_interval), 0.01))
finally:
self.mark_job_complete(job_id)
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,351 +0,0 @@
import argparse
import hashlib
import json
import os
import platform
import queue
import shutil
import subprocess
import sys
import tarfile
import tempfile
import threading
import time
import urllib.request
import zipfile
from pathlib import Path
UV_VERSION = "0.11.3"
ENVIRONMENT_SCHEMA = 1
BASE_PACKAGES = (
"fastapi==0.139.2",
"uvicorn==0.51.0",
)
UV_ARTIFACTS = {
"x86_64-pc-windows-msvc": (
"uv-x86_64-pc-windows-msvc.zip",
"ae681c0aaec7cc96af184648cb88d73f8393ed60fa5880abdd6bdb910f9b227c",
),
"aarch64-apple-darwin": (
"uv-aarch64-apple-darwin.tar.gz",
"2bc3d0c7bf2bd08325b1e170abac6f7e5b3346e1d4eab3370d17cefec934996f",
),
"x86_64-unknown-linux-gnu": (
"uv-x86_64-unknown-linux-gnu.tar.gz",
"c0f3236f146e55472663cfbcc9be3042a9f1092275bbe3fe2a56a6cbfd3da5ce",
),
}
UV_SOURCES = (
f"https://github.com/astral-sh/uv/releases/download/{UV_VERSION}",
f"https://mirror.omoolab.xyz/uv/{UV_VERSION}",
)
def normalized_environment(environment):
"""Validate and normalize a public Environment dictionary."""
if not isinstance(environment, dict):
raise TypeError("environment must be a dict")
python = str(environment.get("python", "")).strip()
if not python:
raise ValueError("environment['python'] is required")
packages = environment.get("packages", ())
platform_packages = environment.get("platform_packages", {})
if not isinstance(packages, (list, tuple)):
raise TypeError("environment['packages'] must be a list")
if not isinstance(platform_packages, dict):
raise TypeError("environment['platform_packages'] must be a dict")
normalized_platforms = {}
for name, values in platform_packages.items():
if not isinstance(values, (list, tuple)):
raise TypeError(f"platform_packages[{name!r}] must be a list")
normalized_platforms[str(name)] = [str(value) for value in values]
return {
"python": python,
"packages": [str(value) for value in packages],
"platform_packages": normalized_platforms,
}
def environment_digest(environment):
payload = json.dumps(
{
"schema": ENVIRONMENT_SCHEMA,
"base_packages": BASE_PACKAGES,
"environment": normalized_environment(environment),
},
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
return hashlib.sha256(payload).hexdigest()
def platform_name():
if sys.platform == "win32":
return "windows"
if sys.platform == "darwin":
return "macos"
return "linux"
def resolved_packages(environment):
environment = normalized_environment(environment)
platform_packages = environment["platform_packages"]
selected = platform_packages.get(
platform_name(),
platform_packages.get("default", ()),
)
return [*BASE_PACKAGES, *environment["packages"], *selected]
def environment_python(storage_root):
directory = Path(storage_root) / ".venv"
if os.name == "nt":
return directory / "Scripts" / "python.exe"
return directory / "bin" / "python"
def uv_path(storage_root):
filename = "uv.exe" if os.name == "nt" else "uv"
return Path(storage_root) / "tools" / filename
def uv_target():
machine = platform.machine().lower()
if sys.platform == "win32" and machine in {"amd64", "x86_64"}:
return "x86_64-pc-windows-msvc"
if sys.platform == "darwin" and machine in {"arm64", "aarch64"}:
return "aarch64-apple-darwin"
if sys.platform.startswith("linux") and machine in {"amd64", "x86_64"}:
return "x86_64-unknown-linux-gnu"
raise RuntimeError(f"Unsupported uv platform: {sys.platform} {machine}")
def write_status(path, progress, message, *, stage, stages, error=None):
payload = {
"progress": min(max(float(progress), 0.0), 1.0),
"message": str(message),
"stage": int(stage),
"stages": int(stages),
"stage_label": "Environment",
}
if error:
payload["error"] = str(error)
Path(path).write_text(
json.dumps(payload, ensure_ascii=False),
encoding="utf-8",
)
def child_environment():
environment = os.environ.copy()
environment["PYTHONIOENCODING"] = "utf-8:backslashreplace"
environment["PYTHONUTF8"] = "1"
for name, value in urllib.request.getproxies().items():
environment.setdefault(f"{name.upper()}_PROXY", value)
return environment
def _download(url, destination, status, stage, stages, start, end):
request = urllib.request.Request(url, headers={"User-Agent": "BlendJob"})
with urllib.request.urlopen(request) as response, destination.open("wb") as output:
total = int(response.headers.get("Content-Length") or 0)
received = 0
while True:
chunk = response.read(256 * 1024)
if not chunk:
break
output.write(chunk)
received += len(chunk)
fraction = received / total if total else 0.5
progress = start + (end - start) * min(fraction, 1.0)
write_status(
status,
progress,
"Installing uv",
stage=stage,
stages=stages,
)
def _verify(path, checksum):
digest = hashlib.sha256()
with Path(path).open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
if digest.hexdigest().lower() != checksum.lower():
raise RuntimeError("uv archive checksum does not match")
def _extract_uv(archive, destination):
destination.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory() as directory:
extracted = Path(directory)
if archive.name.endswith(".zip"):
with zipfile.ZipFile(archive) as package:
package.extractall(extracted)
else:
with tarfile.open(archive, "r:gz") as package:
package.extractall(extracted)
candidates = tuple(extracted.rglob(destination.name))
if not candidates:
raise RuntimeError("uv executable was not found in its archive")
shutil.copy2(candidates[0], destination)
if os.name != "nt":
destination.chmod(destination.stat().st_mode | 0o111)
def install_uv(storage_root, status, stage, stages, start=0.0, end=0.15):
destination = uv_path(storage_root)
if destination.is_file():
write_status(status, end, "uv is ready", stage=stage, stages=stages)
return destination
target = uv_target()
filename, checksum = UV_ARTIFACTS[target]
errors = []
destination.parent.mkdir(parents=True, exist_ok=True)
for source in UV_SOURCES:
descriptor, archive_name = tempfile.mkstemp(suffix=f"-{filename}")
os.close(descriptor)
archive = Path(archive_name)
try:
_download(
f"{source}/{filename}",
archive,
status,
stage,
stages,
start,
end * 0.8,
)
_verify(archive, checksum)
_extract_uv(archive, destination)
write_status(status, end, "uv is ready", stage=stage, stages=stages)
return destination
except Exception as error:
errors.append(f"{source}: {error}")
finally:
archive.unlink(missing_ok=True)
raise RuntimeError("Unable to install uv: " + "; ".join(errors))
def run_command(command, environment=None):
subprocess.check_call(
[str(value) for value in command],
env=environment or child_environment(),
)
def install_packages(
command,
status,
stage,
stages,
start=0.22,
end=0.98,
clock=time.monotonic,
):
process = subprocess.Popen(
[str(value) for value in command],
env=child_environment(),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
)
output = queue.Queue()
def read_output():
for line in process.stdout:
output.put(line)
output.put(None)
threading.Thread(target=read_output, daemon=True).start()
started = clock()
last_line = "Installing packages"
while True:
try:
line = output.get(timeout=0.2)
except queue.Empty:
line = ""
if line is None:
break
if line.strip():
last_line = line.strip()
print(last_line)
elapsed = clock() - started
fraction = min(elapsed / (elapsed + 30.0), 0.97)
write_status(
status,
start + (end - start) * fraction,
last_line,
stage=stage,
stages=stages,
)
return_code = process.wait()
if return_code:
raise subprocess.CalledProcessError(return_code, command)
def install(storage_root, environment, status, stages=1):
environment = normalized_environment(environment)
storage_root = Path(storage_root).resolve()
storage_root.mkdir(parents=True, exist_ok=True)
venv = storage_root / ".venv"
manifest = storage_root / "manifest.json"
expected_digest = environment_digest(environment)
uv = install_uv(storage_root, status, 1, stages)
manifest.unlink(missing_ok=True)
write_status(status, 0.16, "Creating Python Environment", stage=1, stages=stages)
run_command([uv, "venv", "--clear", "--python", environment["python"], venv])
write_status(status, 0.22, "Installing packages", stage=1, stages=stages)
packages = resolved_packages(environment)
install_packages(
[uv, "pip", "install", "--python", environment_python(storage_root), *packages],
status,
1,
stages,
)
manifest.write_text(
json.dumps(
{
"environment_hash": expected_digest,
"python": environment["python"],
"packages": packages,
},
indent=2,
),
encoding="utf-8",
)
write_status(status, 1.0, "Environment is ready", stage=1, stages=stages)
def parse_args():
parser = argparse.ArgumentParser()
parser.add_argument("--storage-root", required=True)
parser.add_argument("--config", required=True)
parser.add_argument("--status", required=True)
parser.add_argument("--stages", type=int, default=1)
return parser.parse_args()
def main():
args = parse_args()
try:
environment = json.loads(Path(args.config).read_text(encoding="utf-8"))
install(args.storage_root, environment, args.status, args.stages)
except Exception as error:
write_status(
args.status,
1.0,
"Environment installation failed",
stage=1,
stages=args.stages,
error=error,
)
raise
if __name__ == "__main__":
main()

View File

@@ -1,13 +0,0 @@
import sys
from pathlib import Path
BLENDJOB_DIRECTORY = Path(__file__).resolve().parent.parent
if str(BLENDJOB_DIRECTORY) not in sys.path:
sys.path.insert(0, str(BLENDJOB_DIRECTORY))
from environment import main
if __name__ == "__main__":
main()

View File

@@ -1,255 +0,0 @@
import json
import threading
from dataclasses import dataclass
from pathlib import Path
from .client import JobResult
@dataclass
class JobOperatorState:
"""Internal state for one modal Job Operator invocation."""
runtime: object
operator: object
controller: object
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 operator_properties(operator):
"""Return every custom Blender Property declared by an Operator class."""
names = []
for class_type in reversed(type(operator).__mro__):
for name in getattr(class_type, "__annotations__", {}):
if name not in names:
names.append(name)
parameters = {
name: _json_value(getattr(operator, name))
for name in names
if hasattr(operator, name)
}
json.dumps(parameters)
return parameters
def _json_value(value):
if value is None or isinstance(value, (bool, float, int, str)):
return value
if isinstance(value, (list, tuple)):
return [_json_value(item) for item in value]
try:
return [_json_value(item) for item in value]
except TypeError:
return value
class JobOperatorBase:
"""Blender mixin that turns one Operator into a complete remote Job."""
bl_options = {"INTERNAL"}
poll_interval = 0.25
starting_message = "Starting task"
job_runtime = None
job_type = ""
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
annotations = {}
for base in reversed(cls.__mro__[1:]):
annotations.update(getattr(base, "__annotations__", {}))
annotations.update(cls.__dict__.get("__annotations__", {}))
if annotations:
cls.__annotations__ = annotations
def request(self, _context):
"""Return the complete JSON parameters submitted to the Server."""
return operator_properties(self)
def response(self, _context, _result):
"""Apply an optional successful result on Blender's main thread."""
def cleanup(self):
"""Release invocation resources after every terminal outcome."""
def controller(self, runtime):
"""Return the operation controller used by this invocation."""
return runtime.server
def execute(self, context):
runtime = self._runtime()
if runtime.active_job is not None:
self.report({"WARNING"}, "Another task is running")
return {"CANCELLED"}
if not self.job_type:
self.report({"ERROR"}, "Set job_type on the Job Operator")
return {"CANCELLED"}
try:
parameters = self.request(context)
if not isinstance(parameters, dict):
raise TypeError("request(context) must return a dict")
json.dumps(parameters)
controller = self.controller(runtime)
except (OSError, RuntimeError, TypeError, ValueError) as error:
self.report({"ERROR"}, str(error))
return {"CANCELLED"}
job = JobOperatorState(runtime, self, controller)
runtime.begin_job(job)
if hasattr(context.window_manager, "progress_begin"):
context.window_manager.progress_begin(0, 100)
runtime.update_ui(context, 0.0, self.starting_message)
self._timer = context.window_manager.event_timer_add(
self.poll_interval,
window=context.window,
)
context.window_manager.modal_handler_add(self)
runtime.redraw_ui(context, force=True)
job.start_thread = threading.Thread(
target=self._submit_job,
args=(job, parameters),
name="BlendJobSubmit",
daemon=True,
)
job.start_thread.start()
return {"RUNNING_MODAL"}
def _submit_job(self, job, parameters):
if job.cancelled:
job.started = True
return
try:
submitted = job.controller.submit(self.job_type, parameters)
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):
runtime = self._runtime()
if event.type == "ESC":
runtime.cancel_active()
if event.type != "TIMER":
return {"PASS_THROUGH"}
job = runtime.active_job
if job is None:
self.remove_job_timer(context)
return {"CANCELLED"}
if job.start_error:
self.report({"ERROR"}, f"Unable to start task: {job.start_error}")
return self._close(context, job, "Task failed to start", cancelled=True)
if not job.started:
return {"RUNNING_MODAL"}
if job.cancelled and not job.job_id:
return self._close(context, job, "Task cancelled", cancelled=True)
try:
status = job.controller.status(job.job_id)
except RuntimeError as error:
return self._finish_failure(context, job, 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"}:
if job.cancelled or state == "cancelled":
return self._close(context, job, "Task cancelled", cancelled=True)
return self._finish_failure(
context,
job,
status.get("error") or "Server task failed",
)
return {"RUNNING_MODAL"}
def _finish_success(self, context, job, status):
try:
complete_status = {
"job_id": job.job_id,
"directory": str(job.directory),
**status,
}
message = self.response(
context,
JobResult.from_status(complete_status),
)
except (OSError, RuntimeError, TypeError, 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 _finish_failure(self, context, job, error):
self.report({"ERROR"}, str(error))
return self._close(context, job, "Task failed", cancelled=True)
def _close(self, context, job, message, *, cancelled):
self.remove_job_timer(context)
if hasattr(context.window_manager, "progress_end"):
context.window_manager.progress_end()
job.runtime.finish_job(job)
job.runtime.update_ui(context, 0.0 if cancelled else 1.0, message)
job.runtime.redraw_ui(context, force=True)
return {"CANCELLED"} if cancelled else {"FINISHED"}
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)
job.runtime.update_ui(context, job.progress, message)
job.runtime.redraw_ui(context, force=True)
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 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 cancel(self, context):
runtime = self._runtime()
job = runtime.active_job
runtime.cancel_active()
self.remove_job_timer(context)
if job is None:
return
if hasattr(context.window_manager, "progress_end"):
context.window_manager.progress_end()
runtime.finish_job(job)
runtime.update_ui(context, 0.0, "Task cancelled")
runtime.redraw_ui(context, force=True)
def _runtime(self):
if self.job_runtime is None:
raise RuntimeError("Use BlenderJobRuntime.JobOperatorBase")
return self.job_runtime

View File

@@ -1,71 +0,0 @@
import argparse
import importlib
import sys
import threading
from pathlib import Path
def parse_args():
parser = argparse.ArgumentParser()
parser.add_argument("--entrypoint", required=True)
parser.add_argument("--storage-root", required=True)
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 load_server(entrypoint):
path = Path(entrypoint).resolve()
if path.name == "__init__.py":
module_root = path.parent.parent
module_name = path.parent.name
else:
module_root = path.parent
module_name = path.stem
addon_root = Path(__file__).resolve().parent.parent
for directory in (module_root, addon_root):
if str(directory) not in sys.path:
sys.path.insert(0, str(directory))
module = importlib.import_module(module_name)
server = getattr(module, "server", None)
if server is None:
raise RuntimeError(f"Entrypoint does not export 'server': {entrypoint}")
return server
def main():
args = parse_args()
server = load_server(args.entrypoint)
server.bind(args.storage_root)
import uvicorn
from blendjob.server import watch_parent
app = server.create_app(args.instance_id)
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():
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, server.shutdown_event),
name="BlendJobParentMonitor",
daemon=True,
).start()
http_server.run()
if __name__ == "__main__":
main()

View File

@@ -1,596 +0,0 @@
import json
import os
import subprocess
import sys
import threading
from pathlib import Path
from .controller import ServerController
from .environment import environment_digest, normalized_environment
from .operator import JobOperatorBase
class EnvironmentController:
"""Install the declared Environment, then run arbitrary project setup."""
def __init__(self, runtime):
self.runtime = runtime
self.process = None
self.log_file = None
self.post_thread = None
self.post_status = {}
self.post_error = ""
self.cancelled = False
self._post_job_id = ""
self._post_stage = 1
self._lock = threading.Lock()
def submit(self, _job_type, _parameters):
runtime = self.runtime
runtime.storage_root()
self.post_thread = None
self.post_status = {}
self.post_error = ""
self.cancelled = False
self._post_job_id = ""
self._post_stage = 1
self.log_file = runtime.environment_log_path().open(
"w", encoding="utf-8"
)
environment = os.environ.copy()
environment["PYTHONIOENCODING"] = "utf-8:backslashreplace"
environment["PYTHONUTF8"] = "1"
self.process = subprocess.Popen(
runtime.install_command(),
stdout=self.log_file,
stderr=subprocess.STDOUT,
cwd=runtime.storage_root(),
creationflags=windows_creation_flags(),
env=environment,
)
return {
"job_id": "environment",
"directory": str(runtime.storage_root()),
}
def status(self, _job_id):
status = self._read_status()
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 {
"message": "Environment installation failed",
**status,
"state": "failed",
"error": status.get("error", "Environment installer failed"),
}
if self.runtime.post_install is None:
return self._success_status()
if self.post_thread is None:
self.post_thread = threading.Thread(
target=self._run_post_install,
name="BlendJobPostInstall",
daemon=True,
)
self.post_thread.start()
if self.post_thread.is_alive():
with self._lock:
post_status = dict(self.post_status)
return {
"progress": post_status.get("progress", 0.0),
"message": post_status.get(
"message",
"Running post-install setup",
),
"stage": post_status.get("stage", 2),
"stage_label": post_status.get("stage_label", "Post Install"),
"state": "running",
}
return self._success_status()
def cancel(self, _job_id):
self.cancelled = True
if self.process is not None and self.process.poll() is None:
self.process.terminate()
if self._post_job_id:
try:
self.runtime.server.cancel(self._post_job_id)
except RuntimeError:
pass
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
def _run_post_install(self):
runtime = self.runtime
runtime._request_progress = self._update_post_progress
runtime._request_cancel_check = lambda: self.cancelled
try:
runtime.post_install(runtime)
except Exception as error:
self.post_error = str(error)
finally:
runtime._request_progress = None
runtime._request_cancel_check = None
def _update_post_progress(self, status):
job_id = str(status.get("job_id", ""))
with self._lock:
if job_id and job_id != self._post_job_id:
self._post_job_id = job_id
self._post_stage += 1
self.post_status = {
**status,
"stage": self._post_stage,
"stage_label": str(status.get("job_type", "Post Install")),
}
def _success_status(self):
if self.cancelled:
return {
"progress": 1.0,
"message": "Environment installation cancelled",
"state": "cancelled",
}
if self.post_error:
return {
"progress": 1.0,
"message": f"Environment is ready; post-install failed: {self.post_error}",
"state": "succeeded",
"post_install_error": self.post_error,
}
return {
"progress": 1.0,
"message": "Environment is ready",
"state": "succeeded",
}
def _read_status(self):
try:
return json.loads(
self.runtime.environment_status_path().read_text(
encoding="utf-8"
)
)
except (FileNotFoundError, json.JSONDecodeError, OSError):
return {}
class BlenderJobRuntime:
"""Own one add-on's Storage Root, Environment, Server and Blender UI."""
def __init__(
self,
server,
*,
storage_root,
environment,
post_install=None,
namespace=None,
):
self.server_definition = server
self.storage_root_factory = storage_root
self.environment = normalized_environment(environment)
self.environment_hash = environment_digest(self.environment)
if post_install is not None and not callable(post_install):
raise TypeError("post_install must be callable")
self.post_install = post_install
self.namespace = namespace or _default_namespace(server.name)
self.active_job = None
self.progress = 0.0
self.message = "Ready"
self._operator_base = None
self._operator_classes = None
self._timer_registered = False
self._registered = False
self._status_bar_draw = self._create_status_bar_draw()
self._request_progress = None
self._request_cancel_check = None
self._environment_controller = EnvironmentController(self)
self.server = ServerController(
self._server_command,
cwd_factory=self.storage_root,
log_path_factory=self.server_log_path,
creationflags=windows_creation_flags(),
)
self.JobOperatorBase = self.operator_base()
def storage_root(self):
configured = (
self.storage_root_factory()
if callable(self.storage_root_factory)
else self.storage_root_factory
)
root = Path(configured).expanduser().resolve()
root.mkdir(parents=True, exist_ok=True)
return root
def environment_directory(self):
return self.storage_root() / ".venv"
def environment_python(self):
directory = self.environment_directory()
if os.name == "nt":
return directory / "Scripts" / "python.exe"
return directory / "bin" / "python"
def environment_manifest_path(self):
return self.storage_root() / "manifest.json"
def environment_config_path(self):
return self.storage_root() / "environment.json"
def environment_status_path(self):
return self.storage_root() / "environment-status.json"
def environment_log_path(self):
return self.storage_root() / "environment.log"
def server_log_path(self):
return self.storage_root() / "server.log"
def environment_manifest(self):
try:
value = json.loads(
self.environment_manifest_path().read_text(encoding="utf-8")
)
except (FileNotFoundError, json.JSONDecodeError, OSError):
return {}
return value if isinstance(value, dict) else {}
def environment_ready(self):
return (
self.environment_python().is_file()
and self.environment_manifest().get("environment_hash")
== self.environment_hash
)
def write_environment_config(self):
path = self.environment_config_path()
path.write_text(
json.dumps(self.environment, indent=2),
encoding="utf-8",
)
return path
def install_command(self):
installer = Path(__file__).with_name("installer") / "environment.py"
config = self.write_environment_config()
return [
sys.executable,
str(installer),
"--storage-root",
str(self.storage_root()),
"--config",
str(config),
"--status",
str(self.environment_status_path()),
"--stages",
"1",
]
def request(self, job_type, parameters, response=None):
"""Directly request one Server Job and return its JobResult."""
if self._request_cancel_check is not None and self._request_cancel_check():
raise RuntimeError("Server request was cancelled")
return self.server.request(
job_type,
parameters,
response=response,
progress=self._request_progress,
cancel_check=self._request_cancel_check,
)
def operator_base(self):
if self._operator_base is not None:
return self._operator_base
runtime = self
class BoundJobOperator(JobOperatorBase):
job_runtime = runtime
BoundJobOperator.__name__ = "JobOperatorBase"
self._operator_base = BoundJobOperator
return BoundJobOperator
def operator_classes(self):
if self._operator_classes is not None:
return self._operator_classes
import bpy
runtime = self
operator_base = self.JobOperatorBase
class InstallEnvironment(operator_base, bpy.types.Operator):
bl_idname = f"{runtime.namespace}.install_environment"
bl_label = "Install Environment"
job_type = "install-environment"
starting_message = "Starting Environment installation"
def request(self, _context):
if not bpy.app.online_access:
raise RuntimeError(
"Online Access is disabled in Blender Preferences"
)
return {}
def controller(self, _runtime):
runtime.server.stop()
return runtime._environment_controller
class CancelJob(bpy.types.Operator):
bl_idname = f"{runtime.namespace}.cancel_job"
bl_label = "Cancel Job"
@classmethod
def poll(cls, _context):
return runtime.active_job is not None
def execute(self, _context):
runtime.cancel_active()
return {"FINISHED"}
class StartServer(bpy.types.Operator):
bl_idname = f"{runtime.namespace}.start_server"
bl_label = "Start Server"
def execute(self, _context):
try:
runtime.server.enable()
except (OSError, RuntimeError) as error:
self.report({"ERROR"}, str(error))
return {"CANCELLED"}
return {"FINISHED"}
class StopServer(bpy.types.Operator):
bl_idname = f"{runtime.namespace}.stop_server"
bl_label = "Stop Server"
def execute(self, _context):
runtime.server.stop()
runtime.redraw_ui()
return {"FINISHED"}
class RestartServer(bpy.types.Operator):
bl_idname = f"{runtime.namespace}.restart_server"
bl_label = "Restart Server"
def execute(self, _context):
try:
runtime.server.restart()
except (OSError, RuntimeError) as error:
self.report({"ERROR"}, str(error))
return {"CANCELLED"}
return {"FINISHED"}
class OpenServerLog(bpy.types.Operator):
bl_idname = f"{runtime.namespace}.open_server_log"
bl_label = "Open Server Log"
def execute(self, _context):
path = runtime.server_log_path()
target = path if path.is_file() else path.parent
result = bpy.ops.wm.path_open(filepath=str(target))
if "FINISHED" not in result:
self.report({"ERROR"}, "Unable to open the Server log")
return {"CANCELLED"}
return {"FINISHED"}
self._operator_classes = (
InstallEnvironment,
CancelJob,
StartServer,
StopServer,
RestartServer,
OpenServerLog,
)
return self._operator_classes
def register(self):
if self._registered:
return
import bpy
for class_type in self.operator_classes():
bpy.utils.register_class(class_type)
bpy.types.STATUSBAR_HT_header.append(self._status_bar_draw)
self._registered = True
self.enable()
def unregister(self):
if not self._registered:
return
import bpy
self.disable()
bpy.types.STATUSBAR_HT_header.remove(self._status_bar_draw)
for class_type in reversed(self.operator_classes()):
bpy.utils.unregister_class(class_type)
self._registered = False
def begin_job(self, job):
if self.active_job is not None:
raise RuntimeError("Another task is running")
self.active_job = job
def finish_job(self, job):
if job.job_id:
job.controller.mark_job_complete(job.job_id)
try:
job.operator.cleanup()
finally:
if self.active_job is job:
self.active_job = None
def cancel_active(self):
job = self.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(self):
job = self.active_job
if job is not None:
self.finish_job(job)
def update_ui(self, context, progress, message):
self.progress = min(max(float(progress), 0.0), 1.0)
self.message = str(message)
window_manager = getattr(context, "window_manager", None)
if window_manager is not None and hasattr(
window_manager, "progress_update"
):
window_manager.progress_update(int(self.progress * 100))
self.redraw_ui(context)
def redraw_ui(self, context=None, force=False):
try:
import bpy
except ModuleNotFoundError:
return
context = context or bpy.context
window_manager = getattr(context, "window_manager", None)
for window in getattr(window_manager, "windows", ()):
screen = getattr(window, "screen", None)
for area in getattr(screen, "areas", ()):
if area.type in {"STATUSBAR", "VIEW_3D", "PREFERENCES"}:
area.tag_redraw()
for region in getattr(area, "regions", ()):
region.tag_redraw()
if not force:
return
workspace = getattr(context, "workspace", None)
if workspace is not None:
try:
workspace.status_text_set_internal(None)
except (AttributeError, RuntimeError, TypeError):
pass
def server_status(self):
return dict(self.server.snapshot)
def server_busy(self):
return self.active_job is not None or self.server.snapshot.get(
"state"
) == "BUSY"
def resource(self, name):
return self.server.resource(name)
def clear_resource(self, name):
return self.server.clear_resource(name)
def enable(self):
try:
import bpy
except ModuleNotFoundError:
return
timers = getattr(bpy.app, "timers", None)
if timers is None or timers.is_registered(self._poll_server):
return
timers.register(self._poll_server, first_interval=1.0, persistent=True)
self._timer_registered = True
def disable(self):
self.cancel_active()
self.close_active()
try:
import bpy
except ModuleNotFoundError:
bpy = None
timers = getattr(getattr(bpy, "app", None), "timers", None)
if timers is not None and timers.is_registered(self._poll_server):
timers.unregister(self._poll_server)
self._timer_registered = False
self.server.detach()
def _poll_server(self):
interval = self.server.poll(available=self.environment_ready())
self.redraw_ui()
return interval
def _draw_status_bar(self, owner, _context):
if self.active_job is None:
return
row = owner.layout.row(align=True)
progress_row = row.row(align=True)
progress_row.ui_units_x = 18.0
progress_row.progress(
factor=self.progress,
type="BAR",
text=self.message,
)
row.operator(
f"{self.namespace}.cancel_job",
text="",
icon="X",
)
def _create_status_bar_draw(self):
runtime = self
def draw_status_bar(owner, context):
runtime._draw_status_bar(owner, context)
return draw_status_bar
def _server_command(self, port, instance_id):
entrypoint_path = self._server_entrypoint()
if entrypoint_path is None:
raise RuntimeError("Job Server does not export a Server entrypoint")
runner = Path(__file__).with_name("runner.py")
return [
str(self.environment_python()),
"-u",
str(runner),
"--entrypoint",
str(entrypoint_path),
"--storage-root",
str(self.storage_root()),
"--host",
"127.0.0.1",
"--port",
str(port),
"--instance-id",
instance_id,
"--parent-pid",
str(os.getpid()),
]
def _server_entrypoint(self):
candidates = []
for module in tuple(sys.modules.values()):
if getattr(module, "server", None) is not self.server_definition:
continue
module_path = getattr(module, "__file__", None)
if module_path:
candidates.append(Path(module_path).resolve())
package_entries = [path for path in candidates if path.name == "__init__.py"]
if package_entries:
return min(package_entries, key=lambda path: len(path.parts))
if candidates:
return min(candidates, key=lambda path: len(path.parts))
return self.server_definition.entrypoint_path
def _default_namespace(server_name):
return str(server_name).split()[0].lower().replace("_", "-")
def windows_creation_flags():
if os.name != "nt":
return 0
return subprocess.CREATE_NO_WINDOW

View File

@@ -1,456 +0,0 @@
import os
import sys
import threading
import time
import traceback
import uuid
from concurrent.futures import ThreadPoolExecutor
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,
storage_root,
directory,
):
self.job_id = job_id
self.job_type = job_type
self.parameters = parameters
self.storage_root = Path(storage_root)
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):
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 progress(self, progress, message, **details):
"""Publish running progress from a Job handler."""
self._update(progress, message, state="running", **details)
def succeed(self, result=None, message="Task complete"):
"""Publish a successful terminal result."""
details = {"result": result} if result is not None else {}
self._update(1.0, message, state="succeeded", **details)
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 Server Resource: {name}") from None
class JobServer:
"""Small FastAPI wrapper with decorator-based job registration."""
def __init__(
self,
name,
*,
storage_root=None,
max_job_history=100,
):
self.name = name
self.max_job_history = max(int(max_job_history), 0)
self.handlers = {}
self.resources = {}
self.resource_factories = {}
self.jobs = {}
self.futures = {}
self.active_job_id = None
self.lock = threading.Lock()
self.executor = ThreadPoolExecutor(
max_workers=1,
thread_name_prefix="BlendJobWorker",
)
self.shutdown_event = threading.Event()
self.started_at = time.time()
self.storage_root = Path(storage_root) if storage_root is not None else None
self.jobs_directory = (
self.storage_root / "jobs" if self.storage_root is not None else None
)
self.entrypoint_module = None
self.entrypoint_path = None
def bind(self, storage_root):
"""Bind the Server to the Runtime-owned Storage Root."""
storage_root = Path(storage_root).resolve()
if self.storage_root is not None:
current = self.storage_root.resolve()
if current != storage_root:
raise RuntimeError(
f"Job Server is already bound to another Storage Root: {current}"
)
self.storage_root = storage_root
self.jobs_directory = storage_root / "jobs"
for name, factory in tuple(self.resource_factories.items()):
if name not in self.resources:
self.resources[name] = factory(self)
return self
def add_resource(self, name, resource):
"""Register shared state owned for the full Server 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(self, name):
"""Register a Resource factory initialized after Storage Root binding."""
def register(factory):
if not name or "/" in name:
raise ValueError(f"Invalid resource name: {name}")
if name in self.resources or name in self.resource_factories:
raise ValueError(f"Resource is already registered: {name}")
self.resource_factories[name] = factory
if self.storage_root is not None:
self.resources[name] = factory(self)
return factory
return register
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 Server Resource without racing a new Job submission."""
with self.lock:
if self.active_job_id is not None or self._queued_job_count():
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):
self.executor.shutdown(wait=False, cancel_futures=True)
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
if self.entrypoint_module is None:
self.entrypoint_module = handler.__module__
module = sys.modules.get(handler.__module__)
module_path = getattr(module, "__file__", None)
if module_path:
self.entrypoint_path = Path(module_path).resolve()
return handler
return register
def snapshot(self, instance_id):
with self.lock:
active = self.jobs.get(self.active_job_id)
queued_jobs = self._queued_job_count()
result = {
"ready": True,
"server": self.name,
"instance_id": instance_id,
"busy": active is not None or queued_jobs > 0,
"active_job": active.snapshot() if active else None,
"queued_jobs": queued_jobs,
"started_at": self.started_at,
"resources": self.resource_snapshots(),
}
return result
def submit(self, job_type, parameters, *, job_id=None):
if self.storage_root is None or self.jobs_directory is None:
raise RuntimeError("Bind the Job Server to a Storage Root before submitting")
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 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,
self.storage_root,
directory,
)
self.jobs[job_id] = context
self._prune_jobs()
self.futures[job_id] = self.executor.submit(self.run, context)
return context
def cancel(self, job_id):
with self.lock:
context = self.jobs.get(job_id)
future = self.futures.get(job_id)
if context is None:
return None
if future is not None and future.cancel():
context.request_cancel()
context._update(1.0, "Task cancelled", state="cancelled")
self.futures.pop(job_id, None)
return context
return context if context.request_cancel() else None
def _queued_job_count(self):
return sum(
context.snapshot()["state"] == "queued"
for context in self.jobs.values()
)
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):
with self.lock:
if context.snapshot()["state"] in TERMINAL_STATES:
return
self.active_job_id = context.job_id
context._update(0.0, "Server accepted the task", state="running")
try:
context.check_cancelled()
result = self.handlers[context.job_type](
context,
context.parameters,
)
context.check_cancelled()
snapshot = context.snapshot()
if snapshot["state"] not in {"failed", "cancelled"}:
context.succeed(
result,
message=snapshot.get("message") or "Task complete",
)
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
self.futures.pop(context.job_id, None)
def create_app(self, instance_id, storage_root=None):
from fastapi import FastAPI, HTTPException
if storage_root is not None:
self.bind(storage_root)
elif self.storage_root is None:
raise RuntimeError("Storage Root is required to create the Job Server app")
@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="Server is busy")
return {"resource": resource_name, "state": "cleared"}
@app.post("/jobs", status_code=202)
def submit_job(payload: dict):
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))
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.cancel(job_id)
if context is None:
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 Server 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

@@ -2,11 +2,15 @@ from pathlib import Path
import bpy
from .blendjob import BlenderJobRuntime
from .server import server
from blendjob import JobRuntime
DEFAULT_STORAGE_ROOT = Path.home() / ".snapform"
BLENDJOB_WHEEL = (
Path(__file__).resolve().parent
/ "wheels"
/ "blendjob-0.1.5-py3-none-any.whl"
)
def configured_storage_root():
@@ -20,6 +24,7 @@ def configured_storage_root():
ENVIRONMENT = {
"python": "3.12",
"packages": [
str(BLENDJOB_WHEEL),
"numpy==2.4.2",
"pillow==12.1.1",
"av==17.0.0",
@@ -35,20 +40,26 @@ ENVIRONMENT = {
def post_install(job_runtime):
errors = []
for model in ("MOGE2_VITS_NORMAL", "BEN2_BASE"):
models = (
("MOGE2_VITS_NORMAL", "MoGe-2 ViT-S Normal"),
("BEN2_BASE", "BEN2 Base"),
)
for model, label in models:
try:
job_runtime.request("download-model", {"model": model})
except RuntimeError as error:
errors.append(str(error))
errors.append(f"{label}: {error}")
if errors:
raise RuntimeError("; ".join(errors))
runtime = BlenderJobRuntime(
server,
runtime = JobRuntime(
"server:server",
entrypoint_root=Path(__file__).parent,
storage_root=configured_storage_root,
environment=ENVIRONMENT,
post_install=post_install,
namespace="snapform",
)
JobOperatorBase = runtime.JobOperatorBase

View File

@@ -186,4 +186,3 @@ def model_record(model, ready=False):
),
"ready": bool(ready),
}

View File

@@ -7,6 +7,7 @@ from .job_runtime import JobOperatorBase, runtime
from .properties import (
BEN2_MODEL_KEY,
DEFAULT_MOGE2_MODEL_KEY,
REQUIRED_MODEL_KEYS,
model_ready,
model_record,
)
@@ -296,7 +297,7 @@ def apply_background_response(result, source_object_name):
push_undo("Remove Background")
def da3_parameters(settings, path, model, process_res, use_ray_pose, start_frame):
def da3_parameters(settings, path, model, process_res, start_frame):
return {
"input": str(path),
"model": model,
@@ -310,7 +311,6 @@ def da3_parameters(settings, path, model, process_res, use_ray_pose, start_frame
"start_frame": start_frame,
"device": production_device().lower(),
"ref_view_strategy": settings.ref_view_strategy,
"use_ray_pose": 1 if use_ray_pose else 0,
}
@@ -342,6 +342,19 @@ class DownloadModel(JobOperatorBase, bpy.types.Operator):
return {"model": self.model}
class DownloadRequiredModels(JobOperatorBase, bpy.types.Operator):
bl_idname = "snapform.download_required_models"
bl_label = "Download Required Models"
job_type = "download-required-models"
starting_message = "Starting required model downloads"
def request(self, _context):
require_environment()
if not bpy.app.online_access:
raise RuntimeError("Online Access is disabled in Blender Preferences")
return {"models": list(REQUIRED_MODEL_KEYS)}
class DebugDa3(JobOperatorBase, bpy.types.Operator):
bl_idname = "snapform.debug_da3"
bl_label = "Debug DA3"
@@ -362,7 +375,6 @@ class DebugDa3(JobOperatorBase, bpy.types.Operator):
input_path(settings.input_path),
model,
settings.process_res,
settings.use_ray_pose,
1,
),
"device": settings.device.lower(),
@@ -439,7 +451,6 @@ class GenerateDa3Depth(JobOperatorBase, bpy.types.Operator):
source_object_name: bpy.props.StringProperty(options=HIDDEN)
depth_data_name: bpy.props.StringProperty(options=HIDDEN)
model: bpy.props.StringProperty(default="BASE", options=HIDDEN)
use_ray_pose: bpy.props.BoolProperty(default=False, options=HIDDEN)
process_res: bpy.props.IntProperty(default=1024, min=1, options=HIDDEN)
delete_source: bpy.props.BoolProperty(default=True, options=HIDDEN)
delete_input: bpy.props.BoolProperty(default=False, options=HIDDEN)
@@ -458,7 +469,6 @@ class GenerateDa3Depth(JobOperatorBase, bpy.types.Operator):
input_path(self.input_path),
self.model,
self.process_res,
self.use_ray_pose,
source_start_frame(self.source_object_name),
),
"export_format": "mini_npz",
@@ -542,7 +552,6 @@ class GenerateDa3Surface(JobOperatorBase, bpy.types.Operator):
input_path: bpy.props.StringProperty(options=HIDDEN)
source_object_name: bpy.props.StringProperty(options=HIDDEN)
model: bpy.props.StringProperty(default="BASE", options=HIDDEN)
use_ray_pose: bpy.props.BoolProperty(default=False, options=HIDDEN)
process_res: bpy.props.IntProperty(default=1024, min=1, options=HIDDEN)
delete_source: bpy.props.BoolProperty(default=True, options=HIDDEN)
delete_input: bpy.props.BoolProperty(default=False, options=HIDDEN)
@@ -562,7 +571,6 @@ class GenerateDa3Surface(JobOperatorBase, bpy.types.Operator):
input_path(self.input_path),
self.model,
self.process_res,
self.use_ray_pose,
source_start_frame(self.source_object_name),
),
"export_format": "mesh_data",
@@ -762,6 +770,7 @@ class ClearModels(bpy.types.Operator):
JOB_OPERATOR_CLASSES = (
DownloadModel,
DownloadRequiredModels,
DebugDa3,
DebugMoge2,
DebugBen2,

View File

@@ -55,12 +55,12 @@ class ServerPanel(bpy.types.Panel):
)
if state == "READY":
controls = row.row(align=True)
operator = controls.operator(
controls.operator(
"snapform.restart_server",
text="",
icon="FILE_REFRESH",
)
operator = controls.operator(
controls.operator(
"snapform.stop_server",
text="",
icon="PAUSE",

View File

@@ -43,7 +43,7 @@ class SnapFormPreferences(bpy.types.AddonPreferences):
icon="IMPORT",
)
layout.separator()
operator = layout.operator(
layout.operator(
"snapform.open_server_log",
text="Open Server Log",
icon="TEXT",

View File

@@ -4,11 +4,13 @@ import sys
import bpy
from .job_runtime import runtime
from . import model_catalog as shared_model_catalog
BEN2_MODEL_KEY = "BEN2_BASE"
DEFAULT_DA3_MODEL_KEY = "BASE"
DEFAULT_MOGE2_MODEL_KEY = "MOGE2_VITS_NORMAL"
REQUIRED_MODEL_KEYS = (DEFAULT_MOGE2_MODEL_KEY, BEN2_MODEL_KEY)
DEVICE_LABELS = {
@@ -17,7 +19,6 @@ DEVICE_LABELS = {
"mps": ("MPS", "Apple Metal (MPS)"),
"cpu": ("CPU", "CPU"),
}
_models_cache = {}
def supported_devices():
@@ -42,22 +43,25 @@ def device_enum_items(_owner, _context):
def models_status(refresh=False):
global _models_cache
status = runtime.server_status().get("resources", {}).get("model_manager", {})
if status:
_models_cache = status
if status and not refresh:
return status
if _models_cache and not refresh:
return _models_cache
if not runtime.environment_ready():
return {}
try:
status = runtime.resource("model_manager")
_models_cache = status
return status
except RuntimeError:
return _models_cache
return {
"catalog": tuple(
shared_model_catalog.model_record(
model,
_model_files_ready(model),
)
for model in shared_model_catalog.DOWNLOADABLE_MODELS.values()
),
"loaded": status.get("loaded", {}),
}
def _model_files_ready(model):
directory = runtime.storage_root() / "models" / model.directory_name
return all(
any(directory.glob(pattern))
for pattern in model.ready_patterns
)
def model_ready(model_key):
@@ -67,6 +71,10 @@ def model_ready(model_key):
)
def required_models_ready():
return all(model_ready(model_key) for model_key in REQUIRED_MODEL_KEYS)
def model_catalog():
return models_status().get("catalog", ())
@@ -243,11 +251,6 @@ class SnapFormSettings(bpy.types.PropertyGroup):
description="Include DA3 camera wireframes in the GLB export",
default=True,
)
use_ray_pose: bpy.props.BoolProperty(
name="Use Ray Pose",
description="Legacy task compatibility; ONNX uses the camera decoder",
default=False,
)
ref_view_strategy: bpy.props.EnumProperty(
name="Ref View Strategy",
description="DA3 reference view selection strategy",

View File

@@ -1,19 +1,6 @@
"""SnapForm Job Server without Blender dependencies."""
import sys
from pathlib import Path
SERVER_DIRECTORY = Path(__file__).resolve().parent
ADDON_DIRECTORY = SERVER_DIRECTORY.parent
for import_directory in (SERVER_DIRECTORY, ADDON_DIRECTORY):
if str(import_directory) not in sys.path:
sys.path.insert(0, str(import_directory))
try:
from ..blendjob import JobServer
except ImportError:
from blendjob import JobServer
from blendjob import JobServer
from .model_manager import ModelManager, ModelSessionCache
from .background import remove_background as _remove_background
@@ -36,6 +23,12 @@ def download_model(context, parameters):
return manager.download(context, parameters["model"])
@server.job("download-required-models")
def download_required_models(context, parameters):
manager = context.resource("model_manager")
return manager.download_missing(context, parameters["models"])
@server.job("debug-ben2")
@server.job("remove-background")
def remove_background(context, parameters):

View File

@@ -121,7 +121,6 @@ def generate_da3(context, parameters):
inference_options = {
"process_res": parameters["process_res"],
"process_res_method": parameters["process_res_method"],
"use_ray_pose": bool(parameters["use_ray_pose"]),
"ref_view_strategy": parameters["ref_view_strategy"],
}
try:

View File

@@ -15,12 +15,13 @@ from urllib.request import (
HF_ENDPOINT = "https://huggingface.co"
R2_ENDPOINT = "https://models.omoolab.xyz"
CONNECT_TIMEOUT = 3.0
CONNECT_TIMEOUT = 10.0
READ_TIMEOUT = 60.0
DOWNLOAD_CHUNK_SIZE = 256 * 1024
class _ShortConnectHTTPSConnection(http.client.HTTPSConnection):
"""Use a short timeout for connection setup, without limiting downloads."""
"""Bound connection and idle reads without limiting total download time."""
def connect(self):
try:
@@ -28,7 +29,7 @@ class _ShortConnectHTTPSConnection(http.client.HTTPSConnection):
super().connect()
finally:
self.timeout = None
self.sock.settimeout(None)
self.sock.settimeout(READ_TIMEOUT)
class _ShortConnectHTTPSHandler(HTTPSHandler):

View File

@@ -3,7 +3,7 @@ import time
from pathlib import Path
from types import SimpleNamespace
from . import model_catalog
import model_catalog
from .model_download import download_model
@@ -157,11 +157,28 @@ class ModelManager:
self.sessions.close()
def download(self, context, model_key):
return self._download(context, model_key, context.progress)
def download_missing(self, context, model_keys):
missing = [key for key in model_keys if not self.ready(key)]
if not missing:
context.progress(1.0, "Required models are ready")
return {"downloaded": []}
total = len(missing)
for index, model_key in enumerate(missing):
def report(value, message, *, offset=index):
context.progress((offset + value) / total, message)
self._download(context, model_key, report)
return {"downloaded": missing}
def _download(self, context, model_key, progress):
model = model_catalog.get_downloadable_model(model_key)
args = SimpleNamespace(
repository=model.repository,
model_dir=str(self.directory(model_key)),
progress=context.progress,
progress=progress,
cancel_check=context.check_cancelled,
label=model.label,
allow_pattern=list(model.download_patterns),

View File

@@ -195,9 +195,7 @@ def infer(
process_res=504,
process_res_method="upper_bound_resize",
ref_view_strategy="saddle_balanced",
use_ray_pose=False,
):
del use_ray_pose
inputs = session.get_inputs()
model_input, processed_images = preprocess(
image_paths,
@@ -240,4 +238,3 @@ def infer(
processed_images=processed_images,
aux={},
)

View File

@@ -1,19 +1,9 @@
"""MoGe-2 ONNX inference and NumPy post-processing."""
from pathlib import Path
import sys
SCRIPT_DIRECTORY = Path(__file__).resolve().parent
if str(SCRIPT_DIRECTORY) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIRECTORY))
try:
from .onnx_runtime import create_session as create_runtime_session
from .onnx_runtime import provider_label, select_providers
except ImportError:
from onnx_runtime import create_session as create_runtime_session
from onnx_runtime import provider_label, select_providers
from .onnx_runtime import create_session as create_runtime_session
from .onnx_runtime import provider_label, select_providers
ONNX_FILENAME = "model.onnx"
@@ -163,4 +153,3 @@ def infer(session, image, resolution_level):
raw_output = dict(zip(names, values))
raw_output["metric_scale"] = raw_output.pop(scale_name)
return postprocess(raw_output)

View File

@@ -69,7 +69,9 @@ class AddonTest(unittest.TestCase):
)
sys.modules["bpy"] = fake_bpy
sys.path.insert(0, str(SOURCE_ROOT))
cls.paths_before_import = tuple(sys.path)
cls.addon = importlib.import_module("snapform")
cls.paths_after_import = tuple(sys.path)
cls.fake_bpy = fake_bpy
@classmethod
@@ -116,8 +118,17 @@ class AddonTest(unittest.TestCase):
self.assertFalse((SOURCE_ROOT / "snapform" / name).exists(), name)
self.assertFalse(hasattr(self.addon, "client"))
def test_import_does_not_add_addon_directories_to_sys_path(self):
self.assertEqual(self.paths_after_import, self.paths_before_import)
self.assertFalse(
any(
name == "snapform.server" or name.startswith("snapform.server.")
for name in sys.modules
)
)
def test_job_request_is_not_part_of_blendjob(self):
blendjob = importlib.import_module("snapform.blendjob")
blendjob = importlib.import_module("blendjob")
self.assertFalse(hasattr(blendjob, "JobRequest"))
def test_job_operators_are_flat_and_use_plain_properties(self):
@@ -217,8 +228,8 @@ class AddonTest(unittest.TestCase):
with patch.object(runtime, "storage_root_factory", root):
self.assertEqual(runtime.storage_root(), root.resolve())
self.assertEqual(runtime.environment_directory(), root / ".venv")
self.assertEqual(runtime.environment_status_path(), root / "environment-status.json")
self.assertEqual(runtime.environment_log_path(), root / "environment.log")
self.assertEqual(runtime.install_status_path(), root / "install-status.json")
self.assertEqual(runtime.install_log_path(), root / "install.log")
self.assertEqual(runtime.server_log_path(), root / "server.log")
def test_runtime_checks_its_environment_dictionary_hash(self):
@@ -274,8 +285,25 @@ class AddonTest(unittest.TestCase):
],
)
def test_snapform_post_install_names_each_failed_model(self):
calls = []
def request(_job_type, parameters):
calls.append(parameters["model"])
raise RuntimeError("network unavailable")
with self.assertRaisesRegex(
RuntimeError,
"MoGe-2 ViT-S Normal: network unavailable; BEN2 Base: network unavailable",
):
self.addon.job_runtime.post_install(
SimpleNamespace(request=request)
)
self.assertEqual(calls, ["MOGE2_VITS_NORMAL", "BEN2_BASE"])
def test_environment_controller_runs_arbitrary_post_install_callable(self):
runtime_module = importlib.import_module("snapform.blendjob.runtime")
runtime_module = importlib.import_module("blendjob.runtime")
runtime = self.addon.runtime
calls = []
started = threading.Event()
@@ -294,7 +322,7 @@ class AddonTest(unittest.TestCase):
post_install,
), patch.object(
runtime,
"environment_status_path",
"install_status_path",
return_value=Path(directory) / "missing.json",
):
self.assertEqual(controller.status("environment")["state"], "running")
@@ -306,8 +334,33 @@ class AddonTest(unittest.TestCase):
self.assertEqual(calls, [runtime])
self.assertEqual(status["state"], "succeeded")
def test_environment_controller_reports_post_install_failure(self):
runtime_module = importlib.import_module("blendjob.runtime")
runtime = self.addon.runtime
def post_install(_runtime):
raise RuntimeError("BEN2 download failed")
controller = runtime_module.EnvironmentController(runtime)
controller.process = SimpleNamespace(poll=lambda: 0, returncode=0)
with tempfile.TemporaryDirectory() as directory, patch.object(
runtime,
"post_install",
post_install,
), patch.object(
runtime,
"install_status_path",
return_value=Path(directory) / "missing.json",
):
controller.status("environment")
controller.post_thread.join(1.0)
status = controller.status("environment")
self.assertEqual(status["state"], "failed")
self.assertEqual(status["error"], "BEN2 download failed")
def test_post_install_progress_resets_without_stage_fraction(self):
runtime_module = importlib.import_module("snapform.blendjob.runtime")
runtime_module = importlib.import_module("blendjob.runtime")
controller = runtime_module.EnvironmentController(self.addon.runtime)
controller._update_post_progress(
@@ -336,6 +389,78 @@ class AddonTest(unittest.TestCase):
self.assertEqual(depth_items[0][0], "MOGE2_VITS_NORMAL")
self.assertEqual(surface_items[0][0], "MOGE2_VITS_NORMAL")
def test_required_model_operator_requests_both_models(self):
operator = self.addon.operators.DownloadRequiredModels()
with patch.object(self.addon.operators, "require_environment"):
parameters = operator.request(None)
self.assertEqual(
parameters,
{"models": ["MOGE2_VITS_NORMAL", "BEN2_BASE"]},
)
def test_image_menu_offers_required_model_repair_when_missing(self):
calls = []
class Layout:
operator_context = ""
enabled = True
def __init__(self, enabled=True):
self.enabled = enabled
def row(self):
return Layout(self.enabled)
def operator(self, identifier, **_options):
calls.append((identifier, self.enabled))
return SimpleNamespace()
def separator(self):
calls.append(("separator", self.enabled))
menu = self.addon.SnapFormImageMenu()
menu.layout = Layout()
with patch.object(
self.addon.runtime,
"environment_ready",
return_value=True,
), patch(
"snapform.properties.required_models_ready",
return_value=False,
):
menu.draw(None)
self.assertEqual(
calls[0],
("snapform.download_required_models", True),
)
self.assertIn(("snapform.convert_to_depth_plane", False), calls)
self.assertIn(("snapform.convert_to_surface", True), calls)
def test_ui_reads_required_model_files_without_starting_server(self):
properties = importlib.import_module("snapform.properties")
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
moge = root / "models" / "moge-2-vits-normal-onnx"
ben2 = root / "models" / "BEN2-ONNX" / "onnx"
moge.mkdir(parents=True)
ben2.mkdir(parents=True)
(moge / "model.onnx").touch()
(ben2 / "model_fp16.onnx").touch()
with patch.object(
self.addon.runtime,
"storage_root",
return_value=root,
), patch.object(
self.addon.runtime,
"resource",
side_effect=AssertionError("UI must not start the Server"),
):
self.assertTrue(properties.required_models_ready())
def test_runtime_request_uses_request_response_shape(self):
result = object()
responses = []
@@ -359,7 +484,7 @@ class AddonTest(unittest.TestCase):
)
def test_server_request_returns_job_result_and_calls_response(self):
blendjob = importlib.import_module("snapform.blendjob")
blendjob = importlib.import_module("blendjob")
controller = blendjob.ServerController.__new__(blendjob.ServerController)
statuses = iter(
(
@@ -389,10 +514,11 @@ class AddonTest(unittest.TestCase):
self.assertEqual(result.value, {"value": 7})
self.assertEqual(responses, [result])
def test_runtime_locates_the_package_exporting_server(self):
entrypoint = self.addon.runtime._server_entrypoint()
self.assertEqual(entrypoint.name, "__init__.py")
self.assertEqual(entrypoint.parent.name, "server")
def test_runtime_keeps_server_as_an_entrypoint_string(self):
path, attribute = self.addon.runtime.server_entrypoint.rsplit(":", 1)
self.assertEqual(Path(path).name, "__init__.py")
self.assertEqual(Path(path).parent.name, "server")
self.assertEqual(attribute, "server")
def test_runtime_registers_system_operators_and_status_bar(self):
self.addon.register()
@@ -510,14 +636,5 @@ class AddonTest(unittest.TestCase):
self.assertEqual(calls[0]["source_object_name"], "Reference")
self.assertFalse(any(key.startswith("snapform_") for key in calls[0]))
def test_models_are_owned_by_the_server_resource(self):
self.assertFalse((SOURCE_ROOT / "snapform" / "models.py").exists())
with tempfile.TemporaryDirectory() as directory:
manager = self.addon.server.ModelManager(Path(directory))
snapshot = manager.snapshot()
self.assertIn("catalog", snapshot)
self.assertTrue(snapshot["catalog"])
if __name__ == "__main__":
unittest.main()

View File

@@ -2,6 +2,7 @@ import os
import sys
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
@@ -55,6 +56,28 @@ class DownloadProgressTest(unittest.TestCase):
)
return progress, reports
def test_https_connection_bounds_connect_and_idle_read_time(self):
connect_timeouts = []
read_timeouts = []
def connect(connection):
connect_timeouts.append(connection.timeout)
connection.sock = SimpleNamespace(
settimeout=read_timeouts.append,
)
connection = self.download._ShortConnectHTTPSConnection("example.com")
with patch.object(
self.download.http.client.HTTPSConnection,
"connect",
new=connect,
):
connection.connect()
self.assertEqual(connect_timeouts, [10.0])
self.assertEqual(read_timeouts, [60.0])
self.assertIsNone(connection.timeout)
def test_reports_aggregated_download_bytes(self):
progress, reports = self.create_progress()

View File

@@ -1,121 +0,0 @@
import importlib.util
import json
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
PROJECT_ROOT = Path(__file__).resolve().parents[1]
def load_environment():
path = PROJECT_ROOT / "src" / "snapform" / "blendjob" / "environment.py"
specification = importlib.util.spec_from_file_location(
"blendjob_environment_test",
path,
)
module = importlib.util.module_from_spec(specification)
specification.loader.exec_module(module)
return module
class EnvironmentTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.environment = load_environment()
def config(self):
return {
"python": "3.12",
"packages": ["numpy==2.4.2"],
"platform_packages": {
"windows": ["onnxruntime-directml==1.24.4"],
"default": ["onnxruntime==1.24.4"],
},
}
def test_normalizes_public_dictionary(self):
self.assertEqual(
self.environment.normalized_environment(self.config()),
self.config(),
)
def test_requires_python_and_list_packages(self):
with self.assertRaises(ValueError):
self.environment.normalized_environment({})
with self.assertRaises(TypeError):
self.environment.normalized_environment(
{"python": "3.12", "packages": "numpy"}
)
def test_environment_digest_tracks_configuration(self):
first = self.environment.environment_digest(self.config())
reordered = {
"platform_packages": self.config()["platform_packages"],
"packages": ["numpy==2.4.2"],
"python": "3.12",
}
self.assertEqual(first, self.environment.environment_digest(reordered))
reordered["packages"].append("pillow==12.1.1")
self.assertNotEqual(first, self.environment.environment_digest(reordered))
def test_selects_platform_packages_and_server_packages(self):
with patch.object(self.environment.sys, "platform", "win32"):
packages = self.environment.resolved_packages(self.config())
self.assertIn("fastapi==0.139.2", packages)
self.assertIn("uvicorn==0.51.0", packages)
self.assertIn("numpy==2.4.2", packages)
self.assertIn("onnxruntime-directml==1.24.4", packages)
self.assertNotIn("onnxruntime==1.24.4", packages)
def test_uses_default_platform_packages(self):
with patch.object(self.environment.sys, "platform", "linux"):
packages = self.environment.resolved_packages(self.config())
self.assertIn("onnxruntime==1.24.4", packages)
def test_install_writes_manifest_from_environment_hash(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
uv = root / "tools" / "uv"
uv.parent.mkdir()
uv.touch()
status = root / "environment-status.json"
with patch.object(
self.environment,
"install_uv",
return_value=uv,
), patch.object(
self.environment,
"run_command",
), patch.object(
self.environment,
"install_packages",
) as install_packages:
self.environment.install(root, self.config(), status, stages=3)
manifest = json.loads((root / "manifest.json").read_text("utf-8"))
final_status = json.loads(status.read_text("utf-8"))
self.assertEqual(
manifest["environment_hash"],
self.environment.environment_digest(self.config()),
)
self.assertIn("numpy==2.4.2", manifest["packages"])
self.assertEqual(final_status["stage"], 1)
self.assertEqual(final_status["stages"], 3)
self.assertEqual(final_status["progress"], 1.0)
self.assertTrue(install_packages.called)
def test_environment_python_is_inside_runtime_storage(self):
path = self.environment.environment_python("storage")
expected = (
Path("storage/.venv/Scripts/python.exe")
if sys.platform == "win32"
else Path("storage/.venv/bin/python")
)
self.assertEqual(path, expected)
if __name__ == "__main__":
unittest.main()

View File

@@ -21,7 +21,7 @@ class ModelTest(unittest.TestCase):
def setUpClass(cls):
cls.models = load_module(
"snapform_models",
"src/snapform/server/model_catalog.py",
"src/snapform/model_catalog.py",
)
def test_base_model_is_default_friendly_apache_model(self):

View File

@@ -2,6 +2,7 @@ import importlib.util
import sys
import unittest
from pathlib import Path
from types import ModuleType
from unittest.mock import patch
import numpy as np
@@ -11,12 +12,17 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1]
def load_module():
snapform = ModuleType("snapform")
snapform.__path__ = [str(PROJECT_ROOT / "src" / "snapform")]
server = ModuleType("snapform.server")
server.__path__ = [str(PROJECT_ROOT / "src" / "snapform" / "server")]
specification = importlib.util.spec_from_file_location(
"snapform_onnx_moge2",
"snapform.server.onnx_moge2",
PROJECT_ROOT / "src" / "snapform" / "server" / "onnx_moge2.py",
)
module = importlib.util.module_from_spec(specification)
specification.loader.exec_module(module)
with patch.dict(sys.modules, {"snapform": snapform, "snapform.server": server}):
specification.loader.exec_module(module)
return module

View File

@@ -65,15 +65,20 @@ class PackTest(unittest.TestCase):
self.assertIn("server/da3.py", names)
self.assertNotIn("server/processing.py", names)
self.assertNotIn("server/progress.py", names)
self.assertIn("server/model_catalog.py", names)
self.assertIn("model_catalog.py", names)
self.assertIn("server/model_download.py", names)
self.assertIn("server/onnx_ben2.py", names)
self.assertIn("server/onnx_da3.py", names)
self.assertIn("server/onnx_moge2.py", names)
self.assertNotIn("server/jobs.py", names)
self.assertIn("server/__init__.py", names)
self.assertIn("blendjob/environment.py", names)
self.assertIn("blendjob/installer/environment.py", names)
self.assertIn(
"wheels/blendjob-0.1.5-py3-none-any.whl",
names,
)
self.assertFalse(
any(name.startswith("blendjob/") for name in names)
)
self.assertNotIn("install_env.py", names)
self.assertNotIn("snapform/__init__.py", names)
self.assertNotIn("importer.py", names)

View File

@@ -44,6 +44,35 @@ class ServerTest(unittest.TestCase):
self.addCleanup(self.jobs_temp.cleanup)
self.storage_root = Path(self.jobs_temp.name)
def test_required_model_download_skips_ready_model(self):
manager = self.server_module.ModelManager(Path("."))
downloads = []
context = SimpleNamespace(progress=lambda *_args: None)
with patch.object(
manager,
"ready",
side_effect=lambda key: key == "BEN2_BASE",
), patch.object(
manager,
"_download",
side_effect=lambda _context, key, _progress: downloads.append(key),
):
result = manager.download_missing(
context,
("MOGE2_VITS_NORMAL", "BEN2_BASE"),
)
self.assertEqual(downloads, ["MOGE2_VITS_NORMAL"])
self.assertEqual(result, {"downloaded": ["MOGE2_VITS_NORMAL"]})
def test_models_are_owned_by_the_server_resource(self):
self.assertFalse((PROJECT_ROOT / "src" / "snapform" / "models.py").exists())
with tempfile.TemporaryDirectory() as directory:
manager = self.server_module.ModelManager(Path(directory))
snapshot = manager.snapshot()
self.assertIn("catalog", snapshot)
self.assertTrue(snapshot["catalog"])
def create_cache(self):
cache = self.server_module.ModelSessionCache.__new__(
self.server_module.ModelSessionCache
@@ -178,6 +207,7 @@ class ServerTest(unittest.TestCase):
set(server.handlers),
{
"download-model",
"download-required-models",
"remove-background",
"extract-frames",
"generate-da3-depth",

View File

@@ -799,7 +799,6 @@ class WorkerTest(unittest.TestCase):
"device": "cpu",
"process_res": 518,
"process_res_method": "lower_bound_resize",
"use_ray_pose": 1,
"ref_view_strategy": "middle",
"conf_thresh_percentile": 35.0,
"num_max_points": 12345,
@@ -823,7 +822,6 @@ class WorkerTest(unittest.TestCase):
options["process_res_method"],
"lower_bound_resize",
)
self.assertTrue(options["use_ray_pose"])
self.assertEqual(options["ref_view_strategy"], "middle")
self.assertEqual(mesh_calls[0][1], [input_path])
self.assertEqual(mesh_calls[0][2:], (35.0, True, 0.2))
@@ -874,7 +872,6 @@ class WorkerTest(unittest.TestCase):
"device": "cpu",
"process_res": 518,
"process_res_method": "upper_bound_resize",
"use_ray_pose": 1,
"ref_view_strategy": "first",
"conf_thresh_percentile": 40.0,
"num_max_points": 1000,

101
uv.lock generated
View File

@@ -6,6 +6,14 @@ resolution-markers = [
"python_full_version < '3.12'",
]
[[package]]
name = "blendjob"
version = "0.1.5"
source = { path = "src/snapform/wheels/blendjob-0.1.5-py3-none-any.whl" }
wheels = [
{ filename = "blendjob-0.1.5-py3-none-any.whl", hash = "sha256:5d745d057ef051c2b76c4bd6423eab3c99aba7619d2d7f561713448e7be729c0" },
]
[[package]]
name = "colorama"
version = "0.4.6"
@@ -169,6 +177,91 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
[[package]]
name = "pillow"
version = "12.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" },
{ url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" },
{ url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" },
{ url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" },
{ url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" },
{ url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" },
{ url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" },
{ url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" },
{ url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" },
{ url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" },
{ url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" },
{ url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" },
{ url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" },
{ url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" },
{ url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" },
{ url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" },
{ url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" },
{ url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" },
{ url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" },
{ url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" },
{ url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" },
{ url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" },
{ url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" },
{ url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" },
{ url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" },
{ url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" },
{ url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" },
{ url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" },
{ url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" },
{ url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" },
{ url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" },
{ url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" },
{ url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" },
{ url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" },
{ url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" },
{ url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" },
{ url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" },
{ url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" },
{ url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" },
{ url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" },
{ url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" },
{ url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" },
{ url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" },
{ url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" },
{ url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" },
{ url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" },
{ url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" },
{ url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" },
{ url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" },
{ url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" },
{ url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" },
{ url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" },
{ url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" },
{ url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" },
{ url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" },
{ url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" },
{ url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" },
{ url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" },
{ url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" },
{ url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" },
{ url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" },
{ url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" },
{ url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" },
{ url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" },
{ url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" },
{ url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" },
{ url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" },
{ url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" },
{ url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" },
{ url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" },
{ url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" },
{ url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" },
{ url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" },
{ url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" },
{ url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" },
{ url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" },
{ url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
@@ -205,20 +298,26 @@ wheels = [
[[package]]
name = "snapform"
version = "0.11.0"
version = "0.12.3"
source = { virtual = "." }
dependencies = [
{ name = "blendjob" },
]
[package.dev-dependencies]
dev = [
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
{ name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
{ name = "pillow" },
{ name = "pytest" },
]
[package.metadata]
requires-dist = [{ name = "blendjob", path = "src/snapform/wheels/blendjob-0.1.5-py3-none-any.whl" }]
[package.metadata.requires-dev]
dev = [
{ name = "numpy" },
{ name = "pillow" },
{ name = "pytest" },
]