Implement AI Host functionality with model caching and job management
- Added ai_host.py to handle AI tasks with FastAPI integration. - Introduced HostState and ModelCache classes for managing client connections and model loading. - Implemented job submission and cancellation endpoints in the AI host. - Enhanced tests for AI host functionality, including client tracking and job cancellation. - Updated existing tests to accommodate changes in the operator and command structure. - Refactored runtime commands to use request-based architecture instead of command-line arguments. - Improved model loading logic to handle CUDA memory management and reuse models efficiently.
This commit is contained in:
@@ -1,10 +1,16 @@
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import ProxyHandler, Request, build_opener
|
||||
|
||||
import bpy
|
||||
|
||||
@@ -13,10 +19,40 @@ from .models import BEN2_MODEL, get_downloadable_model, selected_model_key
|
||||
|
||||
DA3_COMMIT = "3d835ec1a5802d64a8b8b15f817a1ab54809bfe4"
|
||||
BEN2_COMMIT = "2c99a5da477b5523585bfa5c893888a6e818a8f6"
|
||||
RUNTIME_SCHEMA = 3
|
||||
RUNTIME_SCHEMA = 4
|
||||
HOST_SCHEMA = 2
|
||||
HOST_ADDRESS = "127.0.0.1"
|
||||
DEFAULT_STORAGE_ROOT = Path.home() / ".snapform"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HostJobRequest:
|
||||
payload: dict
|
||||
|
||||
|
||||
@dataclass
|
||||
class HostConnection:
|
||||
port: int
|
||||
token: str
|
||||
pid: int
|
||||
instance_id: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class LaunchedHost:
|
||||
process: subprocess.Popen
|
||||
log_file: object
|
||||
|
||||
|
||||
_host_connection = None
|
||||
_launched_host = None
|
||||
_registered_instance_id = None
|
||||
_client_id = uuid.uuid4().hex
|
||||
_host_auto_start = False
|
||||
_host_snapshot = {"state": "STOPPED", "message": "Stopped"}
|
||||
_local_opener = build_opener(ProxyHandler({}))
|
||||
|
||||
|
||||
def preferences():
|
||||
addon = bpy.context.preferences.addons.get(__package__)
|
||||
return addon.preferences if addon is not None else None
|
||||
@@ -124,6 +160,8 @@ def install_runtime_command(status_path):
|
||||
executable,
|
||||
"--runtime-dir",
|
||||
str(runtime_directory()),
|
||||
"--models-dir",
|
||||
str(models_directory()),
|
||||
"--backend",
|
||||
backend.lower(),
|
||||
"--da3-commit",
|
||||
@@ -135,6 +173,419 @@ def install_runtime_command(status_path):
|
||||
]
|
||||
|
||||
|
||||
def host_log_path():
|
||||
return storage_root() / "ai-host.log"
|
||||
|
||||
|
||||
def host_discovery_path():
|
||||
return storage_root() / "host.json"
|
||||
|
||||
|
||||
def host_lock_path():
|
||||
return storage_root() / "host.lock"
|
||||
|
||||
|
||||
def _available_host_port():
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener:
|
||||
listener.bind((HOST_ADDRESS, 0))
|
||||
return listener.getsockname()[1]
|
||||
|
||||
|
||||
def _host_url(connection, path):
|
||||
return f"http://{HOST_ADDRESS}:{connection.port}{path}"
|
||||
|
||||
|
||||
def _host_http_request(connection, method, path, payload=None, timeout=2.0):
|
||||
data = None
|
||||
headers = {"X-SnapForm-Token": connection.token}
|
||||
if payload is not None:
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
request = Request(
|
||||
_host_url(connection, path),
|
||||
data=data,
|
||||
headers=headers,
|
||||
method=method,
|
||||
)
|
||||
try:
|
||||
with _local_opener.open(request, timeout=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"AI Host returned HTTP {error.code}")
|
||||
except (OSError, URLError) as error:
|
||||
raise RuntimeError(f"Unable to reach AI Host: {error}")
|
||||
|
||||
|
||||
def _read_host_discovery():
|
||||
try:
|
||||
payload = json.loads(host_discovery_path().read_text(encoding="utf-8"))
|
||||
return HostConnection(
|
||||
port=int(payload["port"]),
|
||||
token=str(payload["token"]),
|
||||
pid=int(payload["pid"]),
|
||||
instance_id=str(payload["instance_id"]),
|
||||
)
|
||||
except (KeyError, TypeError, ValueError, OSError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def _check_host(connection, timeout=0.3):
|
||||
health = _host_http_request(
|
||||
connection,
|
||||
"GET",
|
||||
"/health",
|
||||
timeout=timeout,
|
||||
)
|
||||
if health.get("host_schema") != HOST_SCHEMA:
|
||||
raise RuntimeError("AI Host protocol is out of date")
|
||||
if health.get("instance_id") != connection.instance_id:
|
||||
raise RuntimeError("AI Host discovery is stale")
|
||||
return health
|
||||
|
||||
|
||||
def _discover_live_host(timeout=0.3):
|
||||
connection = _read_host_discovery()
|
||||
if connection is None:
|
||||
return None, None
|
||||
try:
|
||||
return connection, _check_host(connection, timeout=timeout)
|
||||
except RuntimeError:
|
||||
return None, None
|
||||
|
||||
|
||||
def start_host():
|
||||
global _launched_host
|
||||
|
||||
if _launched_host is not None and _launched_host.process.poll() is None:
|
||||
return
|
||||
_close_launched_host()
|
||||
port = _available_host_port()
|
||||
token = secrets.token_urlsafe(32)
|
||||
instance_id = uuid.uuid4().hex
|
||||
log_file = host_log_path().open("a", encoding="utf-8")
|
||||
command = [
|
||||
str(runtime_python()),
|
||||
"-u",
|
||||
str(Path(__file__).with_name("ai_host.py")),
|
||||
"--host",
|
||||
HOST_ADDRESS,
|
||||
"--port",
|
||||
str(port),
|
||||
"--token",
|
||||
token,
|
||||
"--instance-id",
|
||||
instance_id,
|
||||
"--discovery",
|
||||
str(host_discovery_path()),
|
||||
"--lock",
|
||||
str(host_lock_path()),
|
||||
]
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
stdout=log_file,
|
||||
stderr=subprocess.STDOUT,
|
||||
cwd=storage_root(),
|
||||
creationflags=creation_flags(),
|
||||
)
|
||||
except OSError:
|
||||
log_file.close()
|
||||
raise
|
||||
_launched_host = LaunchedHost(process, log_file)
|
||||
|
||||
|
||||
def ensure_host(timeout=60.0):
|
||||
global _host_auto_start, _host_connection, _host_snapshot
|
||||
|
||||
_host_auto_start = True
|
||||
deadline = time.monotonic() + timeout
|
||||
last_launch = 0.0
|
||||
starting = False
|
||||
while time.monotonic() < deadline:
|
||||
connection, _health = _discover_live_host(timeout=0.5)
|
||||
if connection is not None:
|
||||
_host_connection = connection
|
||||
_register_host_client(connection)
|
||||
health = _check_host(connection, timeout=0.5)
|
||||
_host_snapshot = _format_host_snapshot(connection, health)
|
||||
_redraw_host_ui()
|
||||
return connection
|
||||
if not starting:
|
||||
_host_snapshot = {"state": "STARTING", "message": "Starting"}
|
||||
_redraw_host_ui()
|
||||
starting = True
|
||||
_close_finished_launched_host()
|
||||
if (
|
||||
_launched_host is None
|
||||
and time.monotonic() - last_launch >= 1.0
|
||||
):
|
||||
start_host()
|
||||
last_launch = time.monotonic()
|
||||
time.sleep(0.05)
|
||||
raise RuntimeError(f"AI Host did not become ready; log: {host_log_path()}")
|
||||
|
||||
|
||||
def submit_host_job(request):
|
||||
connection = ensure_host()
|
||||
payload = dict(request.payload)
|
||||
payload["client_id"] = _client_id
|
||||
_host_http_request(
|
||||
connection,
|
||||
"POST",
|
||||
"/jobs",
|
||||
payload=payload,
|
||||
timeout=5.0,
|
||||
)
|
||||
return connection
|
||||
|
||||
|
||||
def stop_host():
|
||||
global _host_auto_start, _host_connection, _host_snapshot
|
||||
global _registered_instance_id
|
||||
|
||||
connection = _host_connection
|
||||
if connection is None:
|
||||
connection, _health = _discover_live_host(timeout=0.5)
|
||||
if connection is None:
|
||||
_close_launched_host(terminate=True)
|
||||
_host_auto_start = False
|
||||
_host_snapshot = {"state": "STOPPED", "message": "Stopped"}
|
||||
return
|
||||
_register_host_client(connection)
|
||||
_host_http_request(
|
||||
connection,
|
||||
"POST",
|
||||
"/shutdown",
|
||||
payload={"client_id": _client_id},
|
||||
timeout=2.0,
|
||||
)
|
||||
_host_auto_start = False
|
||||
deadline = time.monotonic() + 10.0
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
_check_host(connection, timeout=0.2)
|
||||
except RuntimeError:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
else:
|
||||
raise RuntimeError("AI Host did not stop")
|
||||
_host_connection = None
|
||||
_registered_instance_id = None
|
||||
_close_finished_launched_host()
|
||||
_host_snapshot = {"state": "STOPPED", "message": "Stopped"}
|
||||
|
||||
|
||||
def restart_host():
|
||||
global _host_auto_start, _host_connection, _registered_instance_id
|
||||
global _host_snapshot
|
||||
|
||||
connection = ensure_host()
|
||||
_host_http_request(
|
||||
connection,
|
||||
"POST",
|
||||
"/shutdown",
|
||||
payload={"client_id": _client_id},
|
||||
timeout=2.0,
|
||||
)
|
||||
_host_auto_start = True
|
||||
_host_connection = None
|
||||
_registered_instance_id = None
|
||||
_host_snapshot = {"state": "STARTING", "message": "Restarting"}
|
||||
|
||||
|
||||
def enable_host():
|
||||
global _host_auto_start, _host_connection, _host_snapshot
|
||||
|
||||
_host_auto_start = True
|
||||
connection, health = _discover_live_host(timeout=0.2)
|
||||
if connection is not None:
|
||||
_host_connection = connection
|
||||
_register_host_client(connection)
|
||||
_host_snapshot = _format_host_snapshot(connection, health)
|
||||
_redraw_host_ui()
|
||||
return
|
||||
start_host()
|
||||
_host_snapshot = {"state": "STARTING", "message": "Starting"}
|
||||
_redraw_host_ui()
|
||||
|
||||
|
||||
def clear_host_models():
|
||||
connection = ensure_host()
|
||||
return _host_http_request(
|
||||
connection,
|
||||
"POST",
|
||||
"/models/clear",
|
||||
timeout=5.0,
|
||||
)
|
||||
|
||||
|
||||
def cancel_host_job(job_id):
|
||||
connection = ensure_host()
|
||||
return _host_http_request(
|
||||
connection,
|
||||
"DELETE",
|
||||
f"/jobs/{job_id}",
|
||||
payload={"client_id": _client_id},
|
||||
timeout=2.0,
|
||||
)
|
||||
|
||||
|
||||
def detach_host_client():
|
||||
global _host_connection, _registered_instance_id
|
||||
|
||||
connection = _host_connection
|
||||
if connection is not None and _registered_instance_id is not None:
|
||||
try:
|
||||
_host_http_request(
|
||||
connection,
|
||||
"DELETE",
|
||||
f"/clients/{_client_id}",
|
||||
timeout=0.5,
|
||||
)
|
||||
except RuntimeError:
|
||||
pass
|
||||
_host_connection = None
|
||||
_registered_instance_id = None
|
||||
_close_launched_host(terminate=False)
|
||||
|
||||
|
||||
def schedule_host_start():
|
||||
timers = getattr(bpy.app, "timers", None)
|
||||
if timers is None:
|
||||
return
|
||||
if timers.is_registered(_service_host_timer):
|
||||
return
|
||||
timers.register(_service_host_timer, first_interval=1.0, persistent=True)
|
||||
|
||||
|
||||
def unschedule_host():
|
||||
timers = getattr(bpy.app, "timers", None)
|
||||
if timers is None:
|
||||
return
|
||||
if timers.is_registered(_service_host_timer):
|
||||
timers.unregister(_service_host_timer)
|
||||
|
||||
|
||||
def host_status():
|
||||
return dict(_host_snapshot)
|
||||
|
||||
|
||||
def host_busy():
|
||||
return _host_snapshot.get("state") == "BUSY"
|
||||
|
||||
|
||||
def _service_host_timer():
|
||||
global _host_connection, _host_snapshot, _registered_instance_id
|
||||
|
||||
if not runtime_ready():
|
||||
_host_snapshot = {
|
||||
"state": "UNAVAILABLE",
|
||||
"message": "Runtime is not installed",
|
||||
}
|
||||
return 2.0
|
||||
connection, health = _discover_live_host(timeout=0.2)
|
||||
if connection is None:
|
||||
_host_connection = None
|
||||
_registered_instance_id = None
|
||||
_close_finished_launched_host()
|
||||
if not _host_auto_start:
|
||||
if _host_snapshot.get("state") != "STOPPED":
|
||||
_host_snapshot = {"state": "STOPPED", "message": "Stopped"}
|
||||
_redraw_host_ui()
|
||||
return 2.0
|
||||
try:
|
||||
start_host()
|
||||
except OSError as error:
|
||||
_host_snapshot = {"state": "ERROR", "message": str(error)}
|
||||
return 2.0
|
||||
_host_snapshot = {"state": "STARTING", "message": "Starting"}
|
||||
_redraw_host_ui()
|
||||
return 1.0
|
||||
try:
|
||||
_host_connection = connection
|
||||
if _registered_instance_id == connection.instance_id:
|
||||
try:
|
||||
_host_http_request(
|
||||
connection,
|
||||
"POST",
|
||||
f"/clients/{_client_id}/heartbeat",
|
||||
timeout=0.5,
|
||||
)
|
||||
except RuntimeError:
|
||||
_registered_instance_id = None
|
||||
_register_host_client(connection)
|
||||
health = _check_host(connection, timeout=0.5)
|
||||
_host_snapshot = _format_host_snapshot(connection, health)
|
||||
except RuntimeError as error:
|
||||
_host_connection = None
|
||||
_registered_instance_id = None
|
||||
_host_snapshot = {"state": "ERROR", "message": str(error)}
|
||||
_redraw_host_ui()
|
||||
return 5.0
|
||||
|
||||
|
||||
def _register_host_client(connection):
|
||||
global _registered_instance_id
|
||||
|
||||
if _registered_instance_id == connection.instance_id:
|
||||
return
|
||||
label = f"Blender {getattr(bpy.app, 'version_string', '')}".strip()
|
||||
_host_http_request(
|
||||
connection,
|
||||
"POST",
|
||||
"/clients",
|
||||
payload={"client_id": _client_id, "label": label},
|
||||
timeout=2.0,
|
||||
)
|
||||
_registered_instance_id = connection.instance_id
|
||||
|
||||
|
||||
def _format_host_snapshot(connection, health):
|
||||
state = "BUSY" if health.get("busy") else "READY"
|
||||
return {
|
||||
**health,
|
||||
"state": state,
|
||||
"message": "Busy" if state == "BUSY" else "Ready",
|
||||
"port": connection.port,
|
||||
}
|
||||
|
||||
|
||||
def _redraw_host_ui():
|
||||
try:
|
||||
from .status_bar import redraw_ui
|
||||
|
||||
redraw_ui(bpy.context, force=True)
|
||||
except (AttributeError, RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _close_finished_launched_host():
|
||||
if _launched_host is None or _launched_host.process.poll() is None:
|
||||
return
|
||||
_close_launched_host()
|
||||
|
||||
|
||||
def _close_launched_host(terminate=False):
|
||||
global _launched_host
|
||||
|
||||
launched = _launched_host
|
||||
_launched_host = None
|
||||
if launched is None:
|
||||
return
|
||||
if terminate and launched.process.poll() is None:
|
||||
launched.process.terminate()
|
||||
try:
|
||||
launched.process.wait(timeout=3.0)
|
||||
except subprocess.TimeoutExpired:
|
||||
launched.process.kill()
|
||||
launched.process.wait(timeout=3.0)
|
||||
launched.log_file.close()
|
||||
|
||||
|
||||
def download_model_command(model_key, status_path):
|
||||
model = get_downloadable_model(model_key)
|
||||
command = [
|
||||
@@ -153,31 +604,29 @@ def download_model_command(model_key, status_path):
|
||||
return command
|
||||
|
||||
|
||||
def remove_background_command(
|
||||
def remove_background_request(
|
||||
settings,
|
||||
input_path,
|
||||
status_path,
|
||||
result_directory,
|
||||
model_key,
|
||||
):
|
||||
return [
|
||||
str(runtime_python()),
|
||||
str(Path(__file__).with_name("worker.py")),
|
||||
"remove-background",
|
||||
"--input",
|
||||
str(input_path),
|
||||
"--model-dir",
|
||||
str(model_directory(model_key)),
|
||||
"--output-dir",
|
||||
str(result_directory),
|
||||
"--status",
|
||||
str(status_path),
|
||||
"--device",
|
||||
settings.device.lower(),
|
||||
]
|
||||
return HostJobRequest(
|
||||
{
|
||||
"job_id": Path(status_path).stem,
|
||||
"command": "remove-background",
|
||||
"parameters": {
|
||||
"input": str(input_path),
|
||||
"model_dir": str(model_directory(model_key)),
|
||||
"output_dir": str(result_directory),
|
||||
"status": str(status_path),
|
||||
"device": settings.device.lower(),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def reconstruct_command(
|
||||
def reconstruct_request(
|
||||
settings,
|
||||
export_format,
|
||||
status_path,
|
||||
@@ -201,53 +650,39 @@ def reconstruct_command(
|
||||
snapform_selected_ray_pose = (
|
||||
settings.use_ray_pose if use_ray_pose is None else use_ray_pose
|
||||
)
|
||||
command = [
|
||||
str(runtime_python()),
|
||||
str(Path(__file__).with_name("worker.py")),
|
||||
"reconstruct",
|
||||
"--input",
|
||||
str(
|
||||
parameters = {
|
||||
"input": str(
|
||||
Path(input_path)
|
||||
if input_path is not None
|
||||
else Path(bpy.path.abspath(settings.input_path))
|
||||
),
|
||||
"--model-dir",
|
||||
str(model_directory(snapform_selected_key)),
|
||||
"--output-dir",
|
||||
str(result_directory),
|
||||
"--export-format",
|
||||
export_format,
|
||||
"--status",
|
||||
str(status_path),
|
||||
"--process-res",
|
||||
str(snapform_selected_process_res),
|
||||
"--process-res-method",
|
||||
snapform_selected_process_res_method,
|
||||
"--conf-thresh-percentile",
|
||||
str(settings.conf_thresh_percentile),
|
||||
"--num-max-points",
|
||||
str(settings.num_max_points),
|
||||
"--show-cameras",
|
||||
"1" if settings.show_cameras else "0",
|
||||
"--gs-views-interval",
|
||||
str(settings.gs_views_interval),
|
||||
"--video-frames",
|
||||
str(settings.video_frames),
|
||||
"--device",
|
||||
settings.device.lower(),
|
||||
"--use-ray-pose",
|
||||
"1" if snapform_selected_ray_pose else "0",
|
||||
"--ref-view-strategy",
|
||||
settings.ref_view_strategy,
|
||||
]
|
||||
"model_dir": str(model_directory(snapform_selected_key)),
|
||||
"output_dir": str(result_directory),
|
||||
"export_format": export_format,
|
||||
"status": str(status_path),
|
||||
"process_res": snapform_selected_process_res,
|
||||
"process_res_method": snapform_selected_process_res_method,
|
||||
"conf_thresh_percentile": settings.conf_thresh_percentile,
|
||||
"num_max_points": settings.num_max_points,
|
||||
"show_cameras": 1 if settings.show_cameras else 0,
|
||||
"gs_views_interval": settings.gs_views_interval,
|
||||
"video_frames": settings.video_frames,
|
||||
"device": settings.device.lower(),
|
||||
"use_ray_pose": 1 if snapform_selected_ray_pose else 0,
|
||||
"ref_view_strategy": settings.ref_view_strategy,
|
||||
"remove_background_model_dir": "",
|
||||
}
|
||||
if remove_background:
|
||||
command.extend(
|
||||
[
|
||||
"--remove-background-model-dir",
|
||||
str(model_directory(BEN2_MODEL.key)),
|
||||
]
|
||||
parameters["remove_background_model_dir"] = str(
|
||||
model_directory(BEN2_MODEL.key)
|
||||
)
|
||||
return command
|
||||
return HostJobRequest(
|
||||
{
|
||||
"job_id": Path(status_path).stem,
|
||||
"command": "reconstruct",
|
||||
"parameters": parameters,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def creation_flags():
|
||||
|
||||
Reference in New Issue
Block a user