Files
2026-05-06 11:46:55 +08:00

453 lines
16 KiB
Python

import asyncio
import base64
from contextlib import ExitStack
from datetime import datetime
import importlib
import importlib.util
import json
import mimetypes
import os
import subprocess
import sys
import uuid
def _import_dependencies():
dependencies = ["tos", "aiohttp"]
missing = [dep for dep in dependencies if importlib.util.find_spec(dep) is None]
if not missing:
return
print(f"### [Hitem3D] Installing missing dependencies for ComfyUI: {missing}...")
index_url = "https://pypi.tuna.tsinghua.edu.cn/simple"
attempts = [
[sys.executable, "-m", "pip", "install", *missing, "-i", index_url],
[sys.executable, "-m", "pip", "install", *missing],
[sys.executable, "-m", "pip", "install", "--user", *missing],
]
success = False
for cmd in attempts:
try:
result = subprocess.run(cmd, check=False)
if result.returncode == 0:
success = True
break
except Exception:
pass
if success:
importlib.invalidate_caches()
return
raise RuntimeError(
f"Failed to auto-install {missing}. Run manually in a terminal:\n"
f'"{sys.executable}" -m pip install {" ".join(missing)}'
)
_import_dependencies()
import aiohttp
import tos
from comfy.model_management import throw_exception_if_processing_interrupted
class Hitem3dAPI:
API_BASE_URL = "https://api.hitem3d.ai/open-api/v1"
API_HOST = "api.hitem3d.ai"
USER_AGENT = "Apifox/1.0.0 (https://apifox.com)"
REQUEST_TIMEOUT_SECONDS = 30
TASK_POLL_INTERVAL_SECONDS = 10
TASK_POLL_TIMEOUT_SECONDS = 60 * 60
TASK_POLL_REQUEST_TIMEOUT_SECONDS = 30
TOS_ENDPOINT = "tos-ap-southeast-1.volces.com"
TOS_REGION = "ap-southeast-1"
TOS_BUCKET_NAME = "mm-sparc3d-prod"
TOS_PUBLIC_BASE_URL = "https://hitem3dstatic.zaohaowu.net/"
VALID_MESH_FORMATS = {".glb", ".usdz", ".obj", ".fbx", ".stl"}
TASK_PENDING_STATES = {"created", "queueing", "processing"}
def __init__(self, ak, sk):
self.ak = ak
self.sk = sk
def _build_headers(self, authorization=None, extra_headers=None):
headers = {
"User-Agent": self.USER_AGENT,
"Accept": "*/*",
"Host": self.API_HOST,
"Connection": "keep-alive",
}
if authorization:
headers["Authorization"] = authorization
if extra_headers:
headers.update(extra_headers)
return headers
def _guess_mime_type(self, file_path):
mime, _ = mimetypes.guess_type(file_path)
return mime or "image/jpeg"
def _extract_error_message(self, body, fallback):
if isinstance(body, dict):
for key in ("msg", "message", "detail", "error"):
value = body.get(key)
if value:
return str(value)
return fallback
def _build_form_data(self, files, data):
form_data = aiohttp.FormData()
for key, value in data.items():
if value is None:
continue
form_data.add_field(key, str(value))
file_items = files.items() if isinstance(files, dict) else files
for field_name, file_info in file_items:
filename, file_obj, content_type = file_info
form_data.add_field(
field_name,
file_obj,
filename=filename,
content_type=content_type,
)
return form_data
async def _parse_json_response(self, response, error_message):
text = await response.text()
if not text.strip():
return {}
try:
return json.loads(text)
except json.JSONDecodeError as exc:
compact_text = " ".join(text.strip().split())[:300]
raise RuntimeError(
f"{error_message}: invalid JSON response: {compact_text or '<empty>'}"
) from exc
async def _request_json(
self,
session,
method,
path,
authorization=None,
*,
params=None,
data=None,
timeout_seconds=None,
extra_headers=None,
error_message=None,
):
timeout = aiohttp.ClientTimeout(
total=timeout_seconds or self.REQUEST_TIMEOUT_SECONDS
)
url = f"{self.API_BASE_URL}/{path.lstrip('/')}"
request_error = error_message or f"{method} {path} failed"
try:
async with session.request(
method,
url,
headers=self._build_headers(
authorization=authorization,
extra_headers=extra_headers,
),
params=params,
data=data,
timeout=timeout,
) as response:
body = await self._parse_json_response(response, request_error)
return response.status, body
except asyncio.TimeoutError as exc:
raise RuntimeError(f"{request_error}: request timed out") from exc
except aiohttp.ClientError as exc:
raise RuntimeError(f"{request_error}: {exc}") from exc
async def _fetch_access_token(self, session):
token = f"{self.ak}:{self.sk}".encode("utf-8")
auth = "Basic " + base64.b64encode(token).decode("ascii")
status_code, body = await self._request_json(
session,
"POST",
"auth/token",
authorization=auth,
extra_headers={"Content-Type": "application/json"},
data={},
error_message="get access token failed",
)
if status_code != 200 or body.get("code") != 200:
msg = self._extract_error_message(body, "Unknown error")
raise RuntimeError(f"get access token failed: {msg}")
data = body.get("data") or {}
access_token = data.get("accessToken")
if not access_token:
raise RuntimeError("get access token failed: accessToken is missing")
return access_token
async def _get_authorization(self, session):
access_token = await self._fetch_access_token(session)
return "Bearer " + access_token
async def _submit_and_wait(self, session, files, data, authorization):
status_code, body = await self.submit_task(session, files, data, authorization)
return await self.handle_task_response(
session,
status_code,
body,
authorization,
)
async def get_access_token(self):
async with aiohttp.ClientSession() as session:
return await self._fetch_access_token(session)
async def image_to_3d(self, image_path, request_type, face, model, resolution, pbr=None):
pbr_value = int(bool(pbr)) if pbr is not None else None
async with aiohttp.ClientSession() as session:
authorization = await self._get_authorization(session)
data = {
"request_type": request_type,
"face": face,
"resolution": resolution,
"model": model,
"format": 2,
"pbr": pbr_value,
}
with open(image_path, "rb") as image_file:
files = {
"images": (
os.path.basename(image_path),
image_file,
self._guess_mime_type(image_path),
)
}
return await self._submit_and_wait(session, files, data, authorization)
async def multi_view_to_3d(
self,
multi_images,
multi_images_bit,
request_type,
face,
model,
resolution,
pbr=None,
):
pbr_value = int(bool(pbr)) if pbr is not None else None
async with aiohttp.ClientSession() as session:
authorization = await self._get_authorization(session)
data = {
"request_type": request_type,
"face": face,
"resolution": resolution,
"model": model,
"format": 2,
"multi_images_bit": multi_images_bit,
"pbr": pbr_value,
}
with ExitStack() as stack:
files = []
for img_path in multi_images:
if img_path is None:
continue
filename = os.path.basename(img_path)
image_file = stack.enter_context(open(img_path, "rb"))
files.append(
(
"multi_images",
(filename, image_file, self._guess_mime_type(img_path)),
)
)
if not files:
raise ValueError("At least one image is required")
return await self._submit_and_wait(session, files, data, authorization)
async def texture(self, image_path, mesh_url, model, pbr=None):
pbr_value = int(bool(pbr)) if pbr is not None else None
async with aiohttp.ClientSession() as session:
authorization = await self._get_authorization(session)
# Texture node doesn't expose `resolution` in UI. Use model-specific defaults.
if model == "hitem3dv2.1":
texture_resolution = "1536fast"
elif model == "scene-portraitv2.1":
texture_resolution = "1536profast"
else:
texture_resolution = "512"
data = {
"request_type": 2,
"resolution": texture_resolution,
"model": model,
"format": 2,
"mesh_url": mesh_url,
"pbr": pbr_value,
}
with open(image_path, "rb") as image_file:
files = {
"images": (
os.path.basename(image_path),
image_file,
self._guess_mime_type(image_path),
)
}
return await self._submit_and_wait(session, files, data, authorization)
async def upload_file(self, mesh_path):
_, ext = os.path.splitext(os.path.basename(mesh_path))
ext = ext.lower()
if ext not in self.VALID_MESH_FORMATS:
raise ValueError("Invalid mesh file format")
async with aiohttp.ClientSession() as session:
authorization = await self._get_authorization(session)
status_code, body = await self._request_json(
session,
"GET",
"upload/token",
authorization=authorization,
error_message="get upload token failed",
)
if status_code != 200 or body.get("code") != 200:
msg = self._extract_error_message(body, "Unknown error")
raise RuntimeError(f"Upload model failed: {msg}")
data = body.get("data") or {}
access_key_id = data.get("accessKeyId")
secret_access_key = data.get("secretAccessKey")
session_key = data.get("sessionKey")
if not access_key_id or not secret_access_key or not session_key:
raise RuntimeError("Upload model failed: upload credentials are incomplete")
object_key = f"comfyui/mesh/{datetime.now().strftime('%Y-%m-%d')}/{uuid.uuid4()}{ext}"
client = tos.TosClientV2(
access_key_id,
secret_access_key,
self.TOS_ENDPOINT,
self.TOS_REGION,
security_token=session_key,
)
await asyncio.to_thread(
client.put_object_from_file,
self.TOS_BUCKET_NAME,
object_key,
mesh_path,
)
return self.TOS_PUBLIC_BASE_URL + object_key
async def submit_task(self, session, files, data, authorization):
payload = dict(data)
payload["plug_type"] = "COMFYUI"
payload["plug_task_id"] = uuid.uuid4().hex
return await self._request_json(
session,
"POST",
"submit-task",
authorization=authorization,
data=self._build_form_data(files, payload),
error_message="submit task failed",
)
async def handle_task_response(self, session, status_code, body, authorization):
if status_code != 200:
return {
"status": "error",
"message": self._extract_error_message(
body,
f"submit task failed with status code {status_code}",
),
"task_id": None,
}
if body.get("code") != 200:
return {
"status": "error",
"message": self._extract_error_message(
body,
"An unexpected error occurred",
),
"task_id": None,
}
task_id = (body.get("data") or {}).get("task_id")
if not task_id:
return {
"status": "error",
"message": "submit task failed: task_id is missing",
"task_id": None,
}
result = await self.task_status(session, task_id, authorization)
if isinstance(result, str):
return {
"status": "error",
"message": result,
"task_id": task_id,
}
model_url = result.get("url")
if not model_url:
return {
"status": "error",
"message": "No downloadable model URL found in task result",
"task_id": task_id,
}
return {"status": "success", "model_url": model_url, "task_id": task_id}
async def task_status(self, session, task_id, authorization):
start_time = asyncio.get_running_loop().time()
last_state = None
while True:
throw_exception_if_processing_interrupted()
if asyncio.get_running_loop().time() - start_time > self.TASK_POLL_TIMEOUT_SECONDS:
return "Task polling timed out."
try:
status_code, body = await self._request_json(
session,
"GET",
"query-task",
authorization=authorization,
params={"task_id": task_id},
timeout_seconds=self.TASK_POLL_REQUEST_TIMEOUT_SECONDS,
extra_headers={"Accept-Language": "en-US"},
error_message="query task failed",
)
except RuntimeError as exc:
print(f"Polling task retry because of temporary error: {exc}")
await asyncio.sleep(self.TASK_POLL_INTERVAL_SECONDS)
continue
if status_code != 200:
print(f"Polling task returned unexpected status code: {status_code}")
await asyncio.sleep(self.TASK_POLL_INTERVAL_SECONDS)
continue
if body.get("code") != 200:
msg = self._extract_error_message(body, "Unknown error")
return f"Task query failed: {msg}"
data_obj = body.get("data") or {}
state = data_obj.get("state")
if state != last_state:
print(f"Task {task_id} state: {state}")
last_state = state
if state in self.TASK_PENDING_STATES:
await asyncio.sleep(self.TASK_POLL_INTERVAL_SECONDS)
continue
if state == "success":
return data_obj
if state == "failed":
return self._extract_error_message(data_obj, "Task failed.")
print(f"Polling task returned unexpected state: {state}")
await asyncio.sleep(self.TASK_POLL_INTERVAL_SECONDS)