Refactor AI Host to Backend Service and update related components

- Renamed references from "AI Host" to "Backend Service" throughout the codebase for consistency.
- Updated the registration and unregistration of context menus to include the Outliner.
- Changed the job protocol versioning and related constants for clarity.
- Modified the runtime installation messages and error handling to reflect the new terminology.
- Adjusted the user interface labels and messages in panels and operators to align with the new Backend Service terminology.
- Updated tests to ensure they reflect the changes in naming and functionality.
- Changed default value for background depth in geometry nodes asset to -1.0 for better handling of depth planes.
This commit is contained in:
2026-08-12 22:42:39 +08:00
parent e3d023cac5
commit 7d0983529e
17 changed files with 246 additions and 155 deletions

View File

@@ -60,6 +60,7 @@ def register():
)
bpy.types.STATUSBAR_HT_header.append(draw_status_bar)
bpy.types.VIEW3D_MT_object_context_menu.append(draw_image_context_menu)
bpy.types.OUTLINER_MT_object.append(draw_image_context_menu)
runtime.schedule_host_start()
@@ -69,6 +70,7 @@ def unregister():
runtime.unschedule_host()
runtime.detach_host_client()
bpy.types.VIEW3D_MT_object_context_menu.remove(draw_image_context_menu)
bpy.types.OUTLINER_MT_object.remove(draw_image_context_menu)
bpy.types.STATUSBAR_HT_header.remove(draw_status_bar)
if hasattr(bpy.types.Scene, "snapform_settings"):
del bpy.types.Scene.snapform_settings

View File

@@ -19,7 +19,7 @@ if str(SCRIPT_DIRECTORY) not in sys.path:
import worker
HOST_SCHEMA = 2
JOB_PROTOCOL_VERSION = 2
CLIENT_TIMEOUT_SECONDS = 10.0
IDLE_SHUTDOWN_SECONDS = 20.0
@@ -212,7 +212,7 @@ class HostState:
with self.lock:
return {
"ready": True,
"host_schema": HOST_SCHEMA,
"job_protocol_version": JOB_PROTOCOL_VERSION,
"instance_id": self.instance_id,
"busy": self.active_job is not None,
"active_job": dict(self.active_job) if self.active_job else None,
@@ -265,7 +265,7 @@ class HostState:
worker.write_status(
status_path,
0.0,
"AI Host accepted the task",
"Backend Service accepted the task",
state="running",
)
try:
@@ -372,7 +372,7 @@ def create_app(token, instance_id="test"):
state.close()
app = FastAPI(
title="SnapForm AI Host",
title="SnapForm Backend Service",
docs_url=None,
redoc_url=None,
openapi_url=None,
@@ -445,7 +445,7 @@ def create_app(token, instance_id="test"):
command,
parameters["status"],
):
raise HTTPException(status_code=409, detail="AI Host is busy")
raise HTTPException(status_code=409, detail="Backend Service is busy")
worker.write_status(
parameters["status"],
0.0,
@@ -485,7 +485,7 @@ def create_app(token, instance_id="test"):
def clear_models(x_snapform_token: str = Header(default="")):
authorize(x_snapform_token)
if not state.clear_models():
raise HTTPException(status_code=409, detail="AI Host is busy")
raise HTTPException(status_code=409, detail="Backend Service is busy")
return {"state": "cleared"}
@app.post("/shutdown", status_code=202)
@@ -498,7 +498,7 @@ def create_app(token, instance_id="test"):
if result == "shared":
raise HTTPException(status_code=409, detail="Other Blender clients are connected")
if result == "busy":
raise HTTPException(status_code=409, detail="AI Host is busy")
raise HTTPException(status_code=409, detail="Backend Service is busy")
if result != "accepted":
raise HTTPException(status_code=404, detail="Client is not registered")
return {"state": "stopping"}
@@ -524,7 +524,7 @@ def write_discovery(args):
temporary.write_text(
json.dumps(
{
"host_schema": HOST_SCHEMA,
"job_protocol_version": JOB_PROTOCOL_VERSION,
"port": args.port,
"token": args.token,
"pid": os.getpid(),

Binary file not shown.

View File

@@ -12,7 +12,7 @@ platforms = ["windows-x64", "macos-arm64", "linux-x64"]
license = ["SPDX:GPL-3.0-or-later"]
[permissions]
network = "Access the local AI Host and download runtime dependencies and model weights"
network = "Access the local Backend Service and download environment dependencies and model weights"
files = "Read source images and store runtime data"
[build]

View File

@@ -34,7 +34,8 @@ XFORMERS_VERSION = "0.0.33.post2"
ADDICT_VERSION = "2.4.0"
FASTAPI_VERSION = "0.139.2"
UVICORN_VERSION = "0.51.0"
RUNTIME_SCHEMA = 4
ENVIRONMENT_MANIFEST_REVISION = 4
ENVIRONMENT_MANIFEST_FILENAME = "environment_manifest.json"
TORCH_INDEXES = {
"cu128": "https://download.pytorch.org/whl/cu128",
"cpu": "https://download.pytorch.org/whl/cpu",
@@ -429,14 +430,17 @@ def install_stages(args, runtime_dir, python, uv):
def runtime_ready(runtime_dir):
ready_path = runtime_dir / "snapform_ready.json"
ready_path = runtime_dir / ENVIRONMENT_MANIFEST_FILENAME
if not environment_python(runtime_dir).is_file() or not ready_path.is_file():
return False
try:
payload = json.loads(ready_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return False
return payload.get("runtime_schema") == RUNTIME_SCHEMA
return (
payload.get("environment_manifest_revision")
== ENVIRONMENT_MANIFEST_REVISION
)
def installed_devices(args):
@@ -505,7 +509,7 @@ def run_verify(
write_status(
status_path,
progress,
"Checking Runtime",
"Checking Backend Environment",
stage=stage,
stages=stages,
stage_label=stage_label,
@@ -777,11 +781,13 @@ def install(args):
installed_packages.extend(stage_installed)
devices = installed_devices(args)
runtime_dir.mkdir(parents=True, exist_ok=True)
ready_path = runtime_dir / "snapform_ready.json"
ready_path = runtime_dir / ENVIRONMENT_MANIFEST_FILENAME
ready_path.write_text(
json.dumps(
{
"runtime_schema": RUNTIME_SCHEMA,
"environment_manifest_revision": (
ENVIRONMENT_MANIFEST_REVISION
),
"python": "3.12",
"torch": TORCH_VERSION,
"xformers": XFORMERS_VERSION,
@@ -828,10 +834,14 @@ def install(args):
write_status(
args.status,
1.0,
f"Runtime is ready; retry {labels} from Models",
f"Backend Environment is ready; retry {labels} from Models",
)
return
write_status(args.status, 1.0, "Runtime and recommended models ready")
write_status(
args.status,
1.0,
"Backend Environment and recommended models ready",
)
def parse_args():
@@ -852,7 +862,12 @@ def main():
try:
install(args)
except Exception as error:
write_status(args.status, 1.0, "Runtime installation failed", str(error))
write_status(
args.status,
1.0,
"Backend Environment installation failed",
str(error),
)
raise

View File

@@ -1823,7 +1823,7 @@ class ConvertImageToDepthPlane(bpy.types.Operator):
available_models = image_depth_model_items(self, context)
if not available_models:
estimate_box.label(text="No DA3 models installed", icon="ERROR")
estimate_box.label(text="Download one in Host > Models")
estimate_box.label(text="Download one in AI Backend > Models")
else:
estimate_box.prop(self, "snapform_model")
estimate_box.prop(self, "snapform_use_ray_pose")
@@ -1917,8 +1917,18 @@ class SnapFormImageMenu(bpy.types.Menu):
bl_label = "SnapForm"
def draw(self, _context):
from .runtime import runtime_ready
layout = self.layout
layout.operator_context = "INVOKE_DEFAULT"
if not runtime_ready():
operator = layout.operator(
"snapform.run_job",
text="Install Backend",
icon="IMPORT",
)
operator.job_type = "INSTALL_RUNTIME"
return
layout.operator(
"snapform.convert_image_to_depth_plane",
text="Convert to Depth Plane",

View File

@@ -22,6 +22,22 @@ BACKGROUND_REMOVAL_JOB = "REMOVE_BACKGROUND"
IMAGE_BACKGROUND_JOB = "IMAGE_BACKGROUND"
ORIGINAL_BACKGROUND_JOB = "ORIGINAL_BACKGROUND"
ORIGINAL_FRAMES_JOB = "ORIGINAL_FRAMES"
DA3_IMAGE_JOBS = frozenset({IMAGE_DEPTH_JOB, IMAGE_MESH_JOB})
IMAGE_EMPTY_JOBS = frozenset(
{
*DA3_IMAGE_JOBS,
IMAGE_BACKGROUND_JOB,
ORIGINAL_BACKGROUND_JOB,
ORIGINAL_FRAMES_JOB,
}
)
PRODUCTION_AI_JOBS = frozenset(
{
*DA3_IMAGE_JOBS,
IMAGE_BACKGROUND_JOB,
ORIGINAL_BACKGROUND_JOB,
}
)
IMAGE_DEPTH_DEFAULT_PROCESS_RES = 1024
IMAGE_DEPTH_PROCESS_RES_METHOD = "upper_bound_resize"
BEN2_IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".webp"}
@@ -134,7 +150,7 @@ class RunJob(bpy.types.Operator):
job_type: bpy.props.EnumProperty(
items=(
("INSTALL_RUNTIME", "Install Runtime", ""),
("INSTALL_RUNTIME", "Install Backend", ""),
("DOWNLOAD_MODEL", "Download Model", ""),
(RECONSTRUCTION_JOB, "Export DA3 Result", ""),
(IMAGE_DEPTH_JOB, "Convert Image to Depth Plane", ""),
@@ -242,13 +258,7 @@ class RunJob(bpy.types.Operator):
),
temporary_input_path=(
Path(self.snapform_input_path)
if self.job_type in {
IMAGE_DEPTH_JOB,
IMAGE_MESH_JOB,
IMAGE_BACKGROUND_JOB,
ORIGINAL_BACKGROUND_JOB,
ORIGINAL_FRAMES_JOB,
}
if self.job_type in IMAGE_EMPTY_JOBS
and self.snapform_delete_input
else None
),
@@ -311,13 +321,7 @@ class RunJob(bpy.types.Operator):
paths = runtime.new_job_paths(self.job_type)
if (
getattr(self, "snapform_delete_input", False)
and self.job_type in {
IMAGE_DEPTH_JOB,
IMAGE_MESH_JOB,
IMAGE_BACKGROUND_JOB,
ORIGINAL_BACKGROUND_JOB,
ORIGINAL_FRAMES_JOB,
}
and self.job_type in IMAGE_EMPTY_JOBS
):
archived_input = runtime.move_input_into_job(
self.snapform_input_path,
@@ -333,7 +337,7 @@ class RunJob(bpy.types.Operator):
return runtime.install_runtime_command(paths["status"]), paths
if not runtime.runtime_ready():
raise RuntimeError("Install the SnapForm Runtime first")
raise RuntimeError("Install the SnapForm Backend first")
if self.job_type == "DOWNLOAD_MODEL":
self._require_online_access()
@@ -343,13 +347,7 @@ class RunJob(bpy.types.Operator):
paths,
)
if self.job_type in {
IMAGE_DEPTH_JOB,
IMAGE_MESH_JOB,
IMAGE_BACKGROUND_JOB,
ORIGINAL_BACKGROUND_JOB,
ORIGINAL_FRAMES_JOB,
}:
if self.job_type in IMAGE_EMPTY_JOBS:
configured_input = self.snapform_input_path
elif self.job_type == BACKGROUND_REMOVAL_JOB:
configured_input = settings.background_input_path
@@ -413,7 +411,7 @@ class RunJob(bpy.types.Operator):
use_ray_pose = None
process_res = None
model_key = selected_model_key(settings, export_format)
if self.job_type in {IMAGE_DEPTH_JOB, IMAGE_MESH_JOB}:
if self.job_type in DA3_IMAGE_JOBS:
model_key = self.snapform_model_key
use_ray_pose = self.snapform_use_ray_pose
process_res = self.snapform_process_res
@@ -421,7 +419,7 @@ class RunJob(bpy.types.Operator):
if not runtime.model_ready(model_key):
raise RuntimeError(f"Download {model.label} first")
if (
self.job_type in {IMAGE_DEPTH_JOB, IMAGE_MESH_JOB}
self.job_type in DA3_IMAGE_JOBS
and remove_background
and not runtime.model_ready(BEN2_MODEL.key)
):
@@ -441,13 +439,13 @@ class RunJob(bpy.types.Operator):
process_res=process_res,
process_res_method=(
IMAGE_DEPTH_PROCESS_RES_METHOD
if self.job_type in {IMAGE_DEPTH_JOB, IMAGE_MESH_JOB}
if self.job_type in DA3_IMAGE_JOBS
else None
),
use_ray_pose=use_ray_pose,
start_frame=start_frame,
remove_background=(
self.job_type in {IMAGE_DEPTH_JOB, IMAGE_MESH_JOB}
self.job_type in DA3_IMAGE_JOBS
and remove_background
),
device=self._task_device(settings),
@@ -457,12 +455,7 @@ class RunJob(bpy.types.Operator):
def _task_device(self, settings):
"""Debug panels use the scene device; production tasks use Preferences."""
if self.job_type in {
IMAGE_DEPTH_JOB,
IMAGE_MESH_JOB,
IMAGE_BACKGROUND_JOB,
ORIGINAL_BACKGROUND_JOB,
}:
if self.job_type in PRODUCTION_AI_JOBS:
return runtime.production_device()
return settings.device
@@ -474,7 +467,7 @@ class RunJob(bpy.types.Operator):
def _starting_message(self, settings):
labels = {
"INSTALL_RUNTIME": "Starting Runtime installation",
"INSTALL_RUNTIME": "Starting Backend installation",
"DOWNLOAD_MODEL": "Starting model download",
}
if self.job_type == RECONSTRUCTION_JOB:
@@ -612,7 +605,7 @@ class RunJob(bpy.types.Operator):
settings.status = "Task failed"
settings.progress = 0.0
fallback_error = (
"AI Host task failed"
"Backend Service task failed"
if job.uses_host
else "Task subprocess failed"
)
@@ -845,15 +838,15 @@ class CancelJob(bpy.types.Operator):
class ControlHost(bpy.types.Operator):
bl_idname = "snapform.control_host"
bl_label = "Control SnapForm AI Host"
bl_label = "Control SnapForm Backend Service"
action: bpy.props.EnumProperty(
items=(
("START", "Start", "Start or connect to the shared AI Host"),
("START", "Start", "Start or connect to the Backend Service"),
("CLEAR", "Clear Models", "Release cached AI models"),
("RESTART", "Restart", "Restart the shared AI Host"),
("STOP", "Stop", "Stop the shared AI Host"),
("LOG", "Open Log", "Open the AI Host log"),
("RESTART", "Restart", "Restart the Backend Service"),
("STOP", "Stop", "Stop the Backend Service"),
("LOG", "Open Log", "Open the Backend Service log"),
),
options={"HIDDEN", "SKIP_SAVE"},
)
@@ -862,22 +855,22 @@ class ControlHost(bpy.types.Operator):
try:
if self.action == "START":
runtime.enable_host()
message = "AI Host is ready"
message = "Backend Service is ready"
elif self.action == "CLEAR":
runtime.clear_host_models()
message = "AI model cache is cleared"
elif self.action == "RESTART":
runtime.restart_host()
message = "AI Host is restarting"
message = "Backend Service is restarting"
elif self.action == "STOP":
runtime.stop_host()
message = "AI Host is stopping"
message = "Backend Service is stopping"
else:
log_path = runtime.host_log_path()
target = log_path if log_path.is_file() else log_path.parent
result = bpy.ops.wm.path_open(filepath=str(target))
if "FINISHED" not in result:
raise RuntimeError("Unable to open the AI Host log")
raise RuntimeError("Unable to open the Backend Service log")
return {"FINISHED"}
except (OSError, RuntimeError) as error:
self.report({"ERROR"}, str(error))

View File

@@ -7,7 +7,7 @@ from .runtime import model_ready, runtime_ready
class HostPanel(bpy.types.Panel):
bl_label = "Host"
bl_label = "AI Backend"
bl_idname = "SNAPFORM_PT_host"
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
@@ -18,12 +18,15 @@ class HostPanel(bpy.types.Panel):
layout = self.layout
running = active_job() is not None or runtime.host_busy()
if not runtime_ready():
layout.label(text="Runtime is not installed", icon="ERROR")
layout.label(
text="Backend Environment is not installed",
icon="ERROR",
)
install_row = layout.row()
install_row.enabled = not running
operator = install_row.operator(
"snapform.run_job",
text="Install Runtime",
text="Install Backend",
icon="IMPORT",
)
operator.job_type = "INSTALL_RUNTIME"

View File

@@ -11,7 +11,7 @@ class SnapFormPreferences(bpy.types.AddonPreferences):
storage_root: bpy.props.StringProperty(
name="Storage Root",
description=(
"Shared user-writable storage for Runtime, models and jobs; "
"Shared user-writable storage for Backend Environment, models and jobs; "
"all Blender versions can use the same directory"
),
subtype="DIR_PATH",
@@ -35,22 +35,22 @@ class SnapFormPreferences(bpy.types.AddonPreferences):
layout.prop(self, "storage_root")
layout.prop(self, "device")
layout.separator()
layout.label(text=f"Runtime: {runtime_directory()}")
layout.label(text=f"Backend Environment: {runtime_directory()}")
if runtime_ready():
layout.label(text="Runtime is ready", icon="CHECKMARK")
layout.label(text="Backend Environment is ready", icon="CHECKMARK")
else:
install_row = layout.row()
install_row.enabled = active_job() is None
operator = install_row.operator(
"snapform.run_job",
text="Install Runtime",
text="Install Backend",
icon="IMPORT",
)
operator.job_type = "INSTALL_RUNTIME"
layout.separator()
operator = layout.operator(
"snapform.control_host",
text="Open Host Log",
text="Open Service Log",
icon="TEXT",
)
operator.action = "LOG"

View File

@@ -28,8 +28,9 @@ from .models import (
DA3_COMMIT = "3d835ec1a5802d64a8b8b15f817a1ab54809bfe4"
BEN2_COMMIT = "2c99a5da477b5523585bfa5c893888a6e818a8f6"
RUNTIME_SCHEMA = 4
HOST_SCHEMA = 2
ENVIRONMENT_MANIFEST_REVISION = 4
JOB_PROTOCOL_VERSION = 2
ENVIRONMENT_MANIFEST_FILENAME = "environment_manifest.json"
HOST_ADDRESS = "127.0.0.1"
DEFAULT_STORAGE_ROOT = Path.home() / ".snapform"
JOB_FOLDER_NAMES = {
@@ -103,14 +104,17 @@ def runtime_python():
def runtime_ready():
ready_path = runtime_directory() / "snapform_ready.json"
ready_path = runtime_directory() / ENVIRONMENT_MANIFEST_FILENAME
if not runtime_python().is_file() or not ready_path.is_file():
return False
try:
ready = json.loads(ready_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return False
return ready.get("runtime_schema") == RUNTIME_SCHEMA
return (
ready.get("environment_manifest_revision")
== ENVIRONMENT_MANIFEST_REVISION
)
def models_directory():
@@ -140,8 +144,8 @@ def model_ready(model_key):
def runtime_devices():
"""Devices detected by the installed Runtime, or None when unknown."""
ready_path = runtime_directory() / "snapform_ready.json"
"""Devices detected by the Backend Environment, or None when unknown."""
ready_path = runtime_directory() / ENVIRONMENT_MANIFEST_FILENAME
if not ready_path.is_file():
return None
try:
@@ -155,7 +159,7 @@ def runtime_devices():
def machine_devices():
"""Devices the machine can run, fastest first; used before Runtime install."""
"""Devices the machine can run before Backend Environment installation."""
devices = ["cpu"]
if shutil.which("nvidia-smi"):
devices.insert(0, "cuda")
@@ -329,9 +333,11 @@ def _host_http_request(connection, method, path, payload=None, timeout=2.0):
detail = json.loads(error.read().decode("utf-8")).get("detail")
except (json.JSONDecodeError, UnicodeDecodeError):
detail = None
raise RuntimeError(detail or f"AI Host returned HTTP {error.code}")
raise RuntimeError(
detail or f"Backend Service returned HTTP {error.code}"
)
except (OSError, URLError) as error:
raise RuntimeError(f"Unable to reach AI Host: {error}")
raise RuntimeError(f"Unable to reach Backend Service: {error}")
def _read_host_discovery():
@@ -354,10 +360,10 @@ def _check_host(connection, timeout=0.3):
"/health",
timeout=timeout,
)
if health.get("host_schema") != HOST_SCHEMA:
raise RuntimeError("AI Host protocol is out of date")
if health.get("job_protocol_version") != JOB_PROTOCOL_VERSION:
raise RuntimeError("Backend Service job protocol is out of date")
if health.get("instance_id") != connection.instance_id:
raise RuntimeError("AI Host discovery is stale")
raise RuntimeError("Backend Service discovery is stale")
return health
@@ -439,7 +445,9 @@ def ensure_host(timeout=60.0):
start_host()
last_launch = time.monotonic()
time.sleep(0.05)
raise RuntimeError(f"AI Host did not become ready; log: {host_log_path()}")
raise RuntimeError(
f"Backend Service did not become ready; log: {host_log_path()}"
)
def submit_host_job(request):
@@ -485,7 +493,7 @@ def stop_host():
break
time.sleep(0.05)
else:
raise RuntimeError("AI Host did not stop")
raise RuntimeError("Backend Service did not stop")
_host_connection = None
_registered_instance_id = None
_close_finished_launched_host()
@@ -563,7 +571,7 @@ def detach_host_client():
pass
_host_connection = None
_registered_instance_id = None
# 如果 Host 是由当前 Blender 启动的,关闭 Blender 时直接终止它,无需等待空闲超时
# 当前 Blender 启动的 Service 随 Blender 关闭,不等待空闲超时。
_close_launched_host(terminate=True)
@@ -616,7 +624,7 @@ def _service_host_timer():
if not runtime_ready():
_host_snapshot = {
"state": "UNAVAILABLE",
"message": "Runtime is not installed",
"message": "Backend Environment is not installed",
}
return 2.0
connection, health = _discover_live_host(timeout=0.2)

View File

@@ -1282,7 +1282,7 @@ def infer_foreground_frames(
def remove_background(args, model_cache=None, cancel_check=None):
total_started = time.perf_counter()
write_status(args.status, 0.02, "Starting BEN2 Runtime")
write_status(args.status, 0.02, "Starting Backend Environment")
check_cancelled(cancel_check)
if model_cache is None:
@@ -1501,7 +1501,7 @@ def flatten_exported_paths(
def reconstruct(args, model_cache=None, cancel_check=None):
write_status(args.status, 0.02, "Starting AI runtime")
write_status(args.status, 0.02, "Starting Backend Environment")
check_cancelled(cancel_check)
if model_cache is None: