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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,3 +8,4 @@ build/
|
||||
.pytest_cache/
|
||||
|
||||
/models/
|
||||
wheels/
|
||||
100
AGENTS.md
100
AGENTS.md
@@ -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 使用英文字符,节点显示文本可以使用中文
|
||||
- 文档描述当前已实现行为;限制、默认值、命令和文件名必须能从代码或测试中验证
|
||||
|
||||
17
README.md
17
README.md
@@ -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)。
|
||||
|
||||
255
docs/blendjob.md
255
docs/blendjob.md
@@ -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 进程。多个进程可能同时下载同一模型时,业务层仍需要文件锁。
|
||||
@@ -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。
|
||||
|
||||
@@ -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 下载前会在界面显示
|
||||
|
||||
@@ -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` 和本次执行使用的模型。
|
||||
|
||||
@@ -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):模块边界、调用流程、项目结构、常用命令与许可证
|
||||
|
||||
@@ -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 解码掩盖)。
|
||||
|
||||
|
||||
@@ -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 只接收对应模型与输出所需的参数。
|
||||
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -186,4 +186,3 @@ def model_record(model, ready=False):
|
||||
),
|
||||
"ready": bool(ready),
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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={},
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
101
uv.lock
generated
@@ -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" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user