diff --git a/.gitignore b/.gitignore index 7ba9ec1..b4c7ea4 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ build/ .pytest_cache/ /models/ +wheels/ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 269dd81..350a163 100644 --- a/AGENTS.md +++ b/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 使用英文字符,节点显示文本可以使用中文 +- 文档描述当前已实现行为;限制、默认值、命令和文件名必须能从代码或测试中验证 diff --git a/README.md b/README.md index cd2433c..28b5960 100644 --- a/README.md +++ b/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)。 diff --git a/docs/blendjob.md b/docs/blendjob.md deleted file mode 100644 index da62f9a..0000000 --- a/docs/blendjob.md +++ /dev/null @@ -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/` -- `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 -/ -├── .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 进程。多个进程可能同时下载同一模型时,业务层仍需要文件锁。 diff --git a/docs/depth-plane.md b/docs/depth-plane.md index 08f3646..37a3cfe 100644 --- a/docs/depth-plane.md +++ b/docs/depth-plane.md @@ -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。 diff --git a/docs/development.md b/docs/development.md index f041a2e..41c4fa1 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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 下载前会在界面显示 diff --git a/docs/features.md b/docs/features.md index 6044d63..d4c8805 100644 --- a/docs/features.md +++ b/docs/features.md @@ -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` 和本次执行使用的模型。 diff --git a/docs/index.md b/docs/index.md index 5b17a5e..85bb131 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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):模块边界、调用流程、项目结构、常用命令与许可证 diff --git a/docs/server.md b/docs/server.md index c3e78c8..9ddfc5f 100644 --- a/docs/server.md +++ b/docs/server.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//`;两者都失败时当前 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//`;两者都失败时当前 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` 直接检查 `/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//`。两个来源使用相同的连续字节进度并共用固定 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//`,提交响应同时返回 `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 解码掩盖)。 diff --git a/docs/surface.md b/docs/surface.md index 71dcdeb..49ecb30 100644 --- a/docs/surface.md +++ b/docs/surface.md @@ -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 只接收对应模型与输出所需的参数。 diff --git a/pyproject.toml b/pyproject.toml index 9e6f42b..96b2dfe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" } diff --git a/src/snapform/blender/depth_plane.py b/src/snapform/blender/depth_plane.py index 00a3b2e..7b733d6 100644 --- a/src/snapform/blender/depth_plane.py +++ b/src/snapform/blender/depth_plane.py @@ -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", diff --git a/src/snapform/blender/surface.py b/src/snapform/blender/surface.py index 0fba270..7c2ce5c 100644 --- a/src/snapform/blender/surface.py +++ b/src/snapform/blender/surface.py @@ -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: diff --git a/src/snapform/blender_manifest.toml b/src/snapform/blender_manifest.toml index 79f5127..ce41794 100644 --- a/src/snapform/blender_manifest.toml +++ b/src/snapform/blender_manifest.toml @@ -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" diff --git a/src/snapform/blendjob/__init__.py b/src/snapform/blendjob/__init__.py deleted file mode 100644 index 4f21143..0000000 --- a/src/snapform/blendjob/__init__.py +++ /dev/null @@ -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", -) diff --git a/src/snapform/blendjob/client.py b/src/snapform/blendjob/client.py deleted file mode 100644 index a65d5af..0000000 --- a/src/snapform/blendjob/client.py +++ /dev/null @@ -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 diff --git a/src/snapform/blendjob/controller.py b/src/snapform/blendjob/controller.py deleted file mode 100644 index d509b53..0000000 --- a/src/snapform/blendjob/controller.py +++ /dev/null @@ -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 diff --git a/src/snapform/blendjob/environment.py b/src/snapform/blendjob/environment.py deleted file mode 100644 index a14346c..0000000 --- a/src/snapform/blendjob/environment.py +++ /dev/null @@ -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() diff --git a/src/snapform/blendjob/installer/environment.py b/src/snapform/blendjob/installer/environment.py deleted file mode 100644 index e5716ad..0000000 --- a/src/snapform/blendjob/installer/environment.py +++ /dev/null @@ -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() diff --git a/src/snapform/blendjob/operator.py b/src/snapform/blendjob/operator.py deleted file mode 100644 index 2366ce6..0000000 --- a/src/snapform/blendjob/operator.py +++ /dev/null @@ -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 diff --git a/src/snapform/blendjob/runner.py b/src/snapform/blendjob/runner.py deleted file mode 100644 index 480b87f..0000000 --- a/src/snapform/blendjob/runner.py +++ /dev/null @@ -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() diff --git a/src/snapform/blendjob/runtime.py b/src/snapform/blendjob/runtime.py deleted file mode 100644 index 7781840..0000000 --- a/src/snapform/blendjob/runtime.py +++ /dev/null @@ -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 diff --git a/src/snapform/blendjob/server.py b/src/snapform/blendjob/server.py deleted file mode 100644 index e886632..0000000 --- a/src/snapform/blendjob/server.py +++ /dev/null @@ -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) diff --git a/src/snapform/job_runtime.py b/src/snapform/job_runtime.py index da5a55c..616dd3f 100644 --- a/src/snapform/job_runtime.py +++ b/src/snapform/job_runtime.py @@ -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 diff --git a/src/snapform/server/model_catalog.py b/src/snapform/model_catalog.py similarity index 99% rename from src/snapform/server/model_catalog.py rename to src/snapform/model_catalog.py index fffc66a..4e5260b 100644 --- a/src/snapform/server/model_catalog.py +++ b/src/snapform/model_catalog.py @@ -186,4 +186,3 @@ def model_record(model, ready=False): ), "ready": bool(ready), } - diff --git a/src/snapform/operators.py b/src/snapform/operators.py index ee86d7c..3bd4c0b 100644 --- a/src/snapform/operators.py +++ b/src/snapform/operators.py @@ -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, diff --git a/src/snapform/panel.py b/src/snapform/panel.py index 7da1ceb..a6bef0b 100644 --- a/src/snapform/panel.py +++ b/src/snapform/panel.py @@ -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", diff --git a/src/snapform/preferences.py b/src/snapform/preferences.py index 992e247..694233a 100644 --- a/src/snapform/preferences.py +++ b/src/snapform/preferences.py @@ -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", diff --git a/src/snapform/properties.py b/src/snapform/properties.py index e01bb8b..df9d510 100644 --- a/src/snapform/properties.py +++ b/src/snapform/properties.py @@ -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", diff --git a/src/snapform/server/__init__.py b/src/snapform/server/__init__.py index dedfbda..cc84811 100644 --- a/src/snapform/server/__init__.py +++ b/src/snapform/server/__init__.py @@ -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): diff --git a/src/snapform/server/da3.py b/src/snapform/server/da3.py index d9422be..d112d2d 100644 --- a/src/snapform/server/da3.py +++ b/src/snapform/server/da3.py @@ -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: diff --git a/src/snapform/server/model_download.py b/src/snapform/server/model_download.py index 9bc48a3..301d674 100644 --- a/src/snapform/server/model_download.py +++ b/src/snapform/server/model_download.py @@ -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): diff --git a/src/snapform/server/model_manager.py b/src/snapform/server/model_manager.py index 586ed53..09aaa9b 100644 --- a/src/snapform/server/model_manager.py +++ b/src/snapform/server/model_manager.py @@ -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), diff --git a/src/snapform/server/onnx_da3.py b/src/snapform/server/onnx_da3.py index ca9ef7a..4c4a4b7 100644 --- a/src/snapform/server/onnx_da3.py +++ b/src/snapform/server/onnx_da3.py @@ -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={}, ) - diff --git a/src/snapform/server/onnx_moge2.py b/src/snapform/server/onnx_moge2.py index b11a323..121ddc2 100644 --- a/src/snapform/server/onnx_moge2.py +++ b/src/snapform/server/onnx_moge2.py @@ -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) - diff --git a/tests/test_addon.py b/tests/test_addon.py index 546f365..1450ac5 100644 --- a/tests/test_addon.py +++ b/tests/test_addon.py @@ -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() diff --git a/tests/test_download_progress.py b/tests/test_download_progress.py index e5add61..fe3a7d0 100644 --- a/tests/test_download_progress.py +++ b/tests/test_download_progress.py @@ -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() diff --git a/tests/test_install_env.py b/tests/test_install_env.py deleted file mode 100644 index 07c4695..0000000 --- a/tests/test_install_env.py +++ /dev/null @@ -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() diff --git a/tests/test_models.py b/tests/test_models.py index d64de33..7f63876 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -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): diff --git a/tests/test_moge_onnx.py b/tests/test_moge_onnx.py index c7a2537..3124bc3 100644 --- a/tests/test_moge_onnx.py +++ b/tests/test_moge_onnx.py @@ -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 diff --git a/tests/test_pack.py b/tests/test_pack.py index 00fc470..8ff5700 100644 --- a/tests/test_pack.py +++ b/tests/test_pack.py @@ -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) diff --git a/tests/test_server.py b/tests/test_server.py index 2946156..69ca6a8 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -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", diff --git a/tests/test_worker.py b/tests/test_worker.py index 99edf56..86b8a0c 100644 --- a/tests/test_worker.py +++ b/tests/test_worker.py @@ -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, diff --git a/uv.lock b/uv.lock index 3397aa2..9d27dd1 100644 --- a/uv.lock +++ b/uv.lock @@ -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" }, ]