Files
AnyImage/docs/blendjob.md
nan b3a07cde32 Add reusable local HTTP job service for Blender add-ons
- Implement JobClient for handling HTTP requests to the JobServer.
- Create ModalJobOperatorBase for managing Blender job lifecycle.
- Introduce JobContext and JobServer for job management and state handling.
- Add ServiceConnection and ServiceController for managing service processes.
- Implement job submission, cancellation, and resource management endpoints in the server.
- Ensure proper error handling and job state updates throughout the service.
2026-08-16 21:04:10 +08:00

8.1 KiB
Raw Blame History

BlendJob 开发者指南

命名与边界

通用 module 命名为 blendjob。这个名字表达的是 Blender 插件与独立 Job 进程之间的桥接,不绑定 AI、模型框架或 SnapForm。

  • JobClient:运行在 Blender Python 中,通过 HTTP 提交、查询和取消 Job
  • JobServer:对 FastAPI 的通用封装,负责 Job 注册、单任务调度、状态与取消
  • JobContext:单个 Job 的运行上下文,提供工作目录、进度、取消与 Resource
  • ModalJobOperatorBase:可直接继承的 Blender Modal Operator,内置提交线程、轮询、进度、取消和终态处理
  • Resource:由 Job Server 持有、跨多个 Job 复用的状态对象
  • SnapForm Job Server:SnapForm 创建并注册专属 Job 后得到的服务实例;代码变量使用 server 或 snapform_server

blendjob 不知道模型、输出文件或 Blender Operator。插件专属代码负责注册 Job 和 Resource,并把参数交给自己的执行逻辑。

最小接入

服务端只创建 Server 并注册业务函数:

server = JobServer("Example", jobs_directory="D:/example/jobs")


@server.job("generate-depth")
def generate_depth(context, parameters):
    output = context.directory / "depth.npz"
    run_depth(parameters["input"], output)
    context.update(1.0, "Depth is ready", output=str(output))

Blender 端直接继承一个基类。普通任务只需要声明 Controller、Job Type 和怎么传值:

class GenerateDepth(ModalJobOperatorBase):
    bl_idname = "example.generate_depth"
    bl_label = "Generate Depth"
    controller_factory = staticmethod(service_controller)
    job_type = "generate-depth"

    def job_parameters(self, context):
        return {"input": context.scene.source_path}

这个 Operator 已经具备异步提交、Server 生成 Job ID 与目录、HTTP 轮询、进度、Esc 取消、错误报告和 Timer 清理。只有需要把产物导回 Blender 时,才覆盖 on_job_succeeded(context, job, status);产物目录是 job.directory。

Resource

Job 表示一次有终态的操作;Resource 表示在 Service 生命周期内被多个 Job 共用的状态。Resource 可以按需实现 snapshot()、clear() 和 close():

models = server.add_resource("models", ModelManager(models_directory))


@server.job("generate-depth")
def generate_depth(context, parameters):
    models = context.resource("models")
    session = models.get(parameters["model"], parameters["device"])

JobServer 在同一把调度锁下阻止运行中清理 Resource,关闭时按注册逆序调用 close()。Resource Snapshot 自动进入 /health 的 resources 字段。

服务端使用

开发者先创建 JobServer,再用装饰器注册 Job 类型:

from blendjob import JobCancelled, JobServer


server = JobServer("Example Job Server", jobs_directory="D:/example/jobs")


@server.job("generate-depth")
def generate_depth(context, parameters):
    input_path = parameters["input"]
    output_path = context.directory / "depth.npz"
    context.update(0.1, "Loading image")
    context.check_cancelled()

    run_model(input_path, output_path)

    context.update(
        1.0,
        "Depth is ready",
        state="succeeded",
        output=output_path,
    )

处理函数签名固定为 handler(context, parameters)。parameters 必须可由 JSON 表示。客户端提交的 JobRequest 不包含 ID;JobServer 接受请求时自动生成 opaque Job ID,并创建 <jobs_directory>/<job_id>/。处理函数通过 context.job_id 与 context.directory 获取它们。客户端不创建 Job 目录,也不应把输出目录作为业务参数传入。Job API 只保存内存状态和少量结果元数据,不创建 status.json。

context.update() 支持以下通用字段:

  • progress:0.0–1.0
  • message:面向界面的当前步骤
  • state:通常自动推导,也可显式设置 queued、running、succeeded、failed、cancelling 或 cancelled
  • error:失败信息
  • 其它关键字:作为插件自定义状态字段原样返回

长耗时流程应在推理前后和循环内调用 context.check_cancelled()。底层库不可中断时,取消会在它返回后的最近检查点生效。

Blender 端底层 API

JobClient 只依赖 Python 标准库,适合直接放入 Blender 插件:

from blendjob import JobClient, JobRequest, ServiceController


client = JobClient("127.0.0.1", port)
request = JobRequest.create(
    "generate-depth",
    input="D:/images/source.png",
)
submitted = client.submit_request(request)
job_id = submitted["job_id"]
job_directory = submitted["directory"]

status = client.status(job_id)
if status["state"] == "running":
    update_blender_progress(status["progress"], status["message"])

专属 Service 进程由 ServiceController 管理。插件只提供启动命令、工作目录和日志路径,端口、Instance ID、进程句柄、连接验证以及 start()、ensure()、enable()、restart()、stop()、detach() 都由 Controller 负责:

controller = ServiceController(
    lambda port, instance_id: [
        str(environment_python),
        "server.py",
        "--port", str(port),
        "--instance-id", instance_id,
        "--parent-pid", str(blender_pid),
    ],
    cwd_factory=lambda: storage_root,
    log_path_factory=lambda: storage_root / "service.log",
)

connection = controller.ensure()
submitted = connection.client.submit_request(request)
controller.stop()

对应 HTTP API:

  • GET /health:查询服务身份、忙闲状态和插件扩展状态
  • POST /jobs:提交 Job
  • GET /jobs/{job_id}:轮询进度与终态
  • DELETE /jobs/{job_id}:请求取消
  • GET /resources:查询全部 Resource
  • GET /resources/{resource_name}:查询一个 Resource
  • POST /resources/{resource_name}/clear:空闲时清理 Resource
  • POST /shutdown:关闭当前专属服务

Service 只监听 127.0.0.1,专属实例由启动它的 ServiceController 保存随机端口与 Instance ID。这个本地专属进程协议不额外使用 Token 鉴权。

ModalJobOperatorBase 默认使用 Blender Window Manager 的进度接口。插件若有自己的状态栏,可覆盖 update_job_ui() 与 redraw_job_ui();这些属于界面定制,不是接入 Job 所必需的代码。运行态使用通用 ModalJobState,只包含 Controller、Job ID、目录、进度和取消状态。插件特有的完成参数可由 create_job_data() 返回,并通过 job.data 读取,不应加入通用状态类。

生命周期建议

每个 Blender 实例为每个插件启动一个专属 Job Server。随机端口与 Instance ID 由 ServiceController 生成并只保存在内存中,不写公共发现文件,也不扫描其它本地服务。

启动服务时传入 Blender 的 PID。服务同时监听显式 /shutdown 和父进程存活状态,因此正常禁用插件时可立即停止,Blender 异常退出后也能自行退出。这个方案允许同一机器出现多个服务进程,换取明确的所有权和插件隔离。

模型与 Environment 可以继续放在插件自己的共享 Storage Root 中;共享磁盘缓存不代表共享服务进程。若多个 Blender 可能同时写同一个模型目录,插件仍需对下载或更新操作单独加文件锁。

在其它插件复用

当前实现位于 src/snapform/blendjob/,没有 SnapForm 依赖。复用时复制整个 blendjob package,并在插件专属入口中完成以下工作:

  1. 创建带 jobs_directory 的 JobServer
  2. 用 @server.job() 注册插件自己的处理函数
  3. 用 server.add_resource() 注册需要跨 Job 复用的状态
  4. 用安装环境中的 Uvicorn 运行 server.create_app(instance_id)
  5. Blender 端用 ServiceController 管理启动、停止、重启和连接检查
  6. Blender Operator 继承 ModalJobOperatorBase,声明 controller_factory、job_type 与 job_parameters()
  7. 需要导入产物时覆盖 on_job_succeeded()

后续若多个项目稳定复用,可把 blendjob 发布为独立 Python package;现阶段保持源码内嵌可以避免为 Blender 扩展增加安装依赖。