← Files Aholo Lux3DARCHIVED FILE
skills/lux3d/core/runtime/lux3d_client.py
52.6 KB · Oct 4, 2026 · 12:33 UTC
"""Public Lux3D API client.
The client accepts accessible HTTP(S) URLs. Upload local files with the Asset
upload API before calling a Lux3D task endpoint.
"""
import argparse
import json
import os
import sys
import time
import urllib.parse
import requests
CN_BASE_URL = "https://api.aholo3d.cn"
INTERNATIONAL_BASE_URL = "https://api.aholo3d.com/global"
REGION_BASE_URLS = {
"cn": CN_BASE_URL,
"international": INTERNATIONAL_BASE_URL,
}
REGION_API_KEY_ENV = {
"cn": "LUX3D_CN_API_KEY",
"international": "LUX3D_GLOBAL_API_KEY",
}
ASSET_UPLOAD_DOCS = {
"cn": "https://labs.aholo3d.cn/api-docs/api-reference#tag/asset",
"international": "https://labs.aholo3d.com/api-docs/en/api-reference#tag/asset",
}
DEFAULT_REGION = "cn"
REQUEST_TIMEOUT = 30
MAX_RETRIES = 3
RETRY_DELAY = 2
DEFAULT_POLL_ATTEMPTS = 60
DEFAULT_POLL_INTERVAL = 15
DOWNLOAD_CHUNK_SIZE = 1024 * 1024
SUPPORTED_STYLES = {
"photorealistic",
"cartoon",
"anime",
"hand_painted",
"cyberpunk",
"fantasy",
"glass",
}
GENERATION_VERSIONS = {"G1", "G1-Turbo"}
MATERIAL_VERSION = "v3.0-standard"
GENERATION_FORMATS = {"zip", "glb", "ply"}
MATERIAL_FORMATS = {"zip", "glb", "usdz", "obj_zip", "fbx_zip"}
EXPORT_FORMATS = {"usdz", "obj_zip", "fbx_zip", "stl", "3mf"}
DOWNLOAD_FORMATS = GENERATION_FORMATS | MATERIAL_FORMATS | EXPORT_FORMATS
SUPPORTED_TASK_STATUSES = {0, 1, 3, 4, 6}
FACE_COUNT_MIN = 10000
FACE_COUNT_MAX = 300000
ARTIFACT_TTL_SECONDS = 2 * 60 * 60
MATERIAL_OUTPUT_SLOTS = ("zip", "glb", "usdz", "obj_zip", "fbx_zip")
EXPORT_OUTPUT_SLOTS = (
"zip",
"glb",
"usdz",
"obj_zip",
"fbx_zip",
"stl",
"3mf",
)
class ApiBusinessRejection(RuntimeError):
"""A complete API envelope that explicitly rejected the request."""
def __init__(self, code, message, payload=None):
self.code = code
self.payload = payload
super().__init__(f"API error: {message or 'unknown error'} (code={code})")
class ApiProtocolError(RuntimeError):
"""An API response that does not satisfy the documented response contract."""
class TaskQueryError(RuntimeError):
"""A query failure carrying the last provider status observed, if any."""
def __init__(self, message, last_provider_status=None):
self.last_provider_status = last_provider_status
super().__init__(message)
class TaskOutputError(RuntimeError):
"""A succeeded provider task whose output violates the output contract."""
def __init__(self, message, task_data=None):
self.last_provider_status = 3
self.task_data = task_data
super().__init__(message)
class TaskPollingTimeout(TimeoutError):
"""A polling limit that carries the last provider status observed."""
def __init__(self, message, last_provider_status=None):
self.last_provider_status = last_provider_status
super().__init__(message)
def get_api_key(region):
"""Read only the API key explicitly bound to the selected region."""
selected_region = normalize_region(region)
dedicated_name = REGION_API_KEY_ENV[selected_region]
dedicated_key = os.environ.get(dedicated_name, "").strip()
if dedicated_key:
return dedicated_key
legacy_key = os.environ.get("LUX3D_API_KEY", "").strip()
if not legacy_key:
return ""
legacy_region = os.environ.get("LUX3D_API_KEY_REGION", "").strip()
if not legacy_region:
raise ValueError(
"Legacy LUX3D_API_KEY is not region-bound. Set "
f"{dedicated_name}, or set LUX3D_API_KEY_REGION={selected_region}."
)
bound_region = normalize_region(legacy_region)
if bound_region != selected_region:
raise ValueError(
"Legacy LUX3D_API_KEY is bound to region "
f"'{bound_region}' and cannot be used for '{selected_region}'."
)
return legacy_key
def validate_api_key(region):
"""Return the API key bound to region or raise a useful error."""
selected_region = normalize_region(region)
api_key = get_api_key(selected_region)
if not api_key or api_key in {"your_lux3d_api_key", "your_api_key"}:
dedicated_name = REGION_API_KEY_ENV[selected_region]
raise ValueError(
f"API key not configured for region '{selected_region}'. Set "
f"{dedicated_name}, or bind legacy LUX3D_API_KEY with "
f"LUX3D_API_KEY_REGION={selected_region}."
)
return api_key
def normalize_region(region=None):
"""Normalize supported region aliases and reject unknown values."""
value = region
if value is None:
value = os.environ.get("LUX3D_REGION", DEFAULT_REGION)
value = str(value).strip().lower()
aliases = {
"cn": "cn",
"china": "cn",
"domestic": "cn",
"international": "international",
"global": "international",
"intl": "international",
}
if value not in aliases:
raise ValueError("region must be 'cn' or 'international'")
return aliases[value]
def get_base_url(region=None):
"""Return the configured Lux3D API root."""
configured = os.environ.get("LUX3D_BASE_URL", "").strip()
if configured:
return normalize_base_url(configured, region)
return REGION_BASE_URLS[normalize_region(region)]
def normalize_base_url(base_url=None, region=None):
"""Normalize a custom base URL to the documented Lux3D API root."""
if not base_url:
base_url = os.environ.get("LUX3D_BASE_URL", "").strip()
if not base_url:
return REGION_BASE_URLS[normalize_region(region)]
normalized = str(base_url).strip().rstrip("/")
if normalized == "https://api.aholo3d.com":
normalized = INTERNATIONAL_BASE_URL
explicit_region = region
if explicit_region is None:
explicit_region = os.environ.get("LUX3D_REGION", "").strip() or None
public_url_regions = {
CN_BASE_URL: "cn",
INTERNATIONAL_BASE_URL: "international",
}
if normalized in public_url_regions and explicit_region is not None:
selected_region = normalize_region(explicit_region)
actual_region = public_url_regions[normalized]
if selected_region != actual_region:
raise ValueError(
f"base_url belongs to region '{actual_region}', not "
f"'{selected_region}'; China and Global credentials/endpoints "
"must remain isolated"
)
return normalized
def resolve_request_region(base_url=None, region=None):
"""Resolve the credential region from an explicit region or public API root."""
normalized_base_url = normalize_base_url(base_url, region)
public_url_regions = {
CN_BASE_URL: "cn",
INTERNATIONAL_BASE_URL: "international",
}
if normalized_base_url in public_url_regions:
return public_url_regions[normalized_base_url]
return normalize_region(region)
def get_asset_upload_docs(region=None):
"""Return the Asset upload documentation for a region."""
return ASSET_UPLOAD_DOCS[normalize_region(region)]
def validate_http_url(value, field_name, region=None):
"""Validate a public HTTP(S) URL used by an API file field."""
if not isinstance(value, str) or not value.strip():
raise ValueError(f"{field_name} must be a non-empty HTTP(S) URL")
normalized = value.strip()
parsed = urllib.parse.urlparse(normalized)
if parsed.scheme.lower() not in {"http", "https"} or not parsed.netloc:
docs = get_asset_upload_docs(region)
raise ValueError(
f"{field_name} must be an accessible HTTP(S) URL. "
f"Upload local files first: {docs}"
)
return normalized
def validate_url_suffix(value, field_name, suffixes, region=None):
"""Validate a URL and its decoded path suffix."""
normalized = validate_http_url(value, field_name, region)
path = urllib.parse.unquote(urllib.parse.urlparse(normalized).path).lower()
if not any(path.endswith(suffix) for suffix in suffixes):
expected = " or ".join(suffixes)
raise ValueError(f"{field_name} URL path must end with {expected}")
return normalized
def validate_output_path(output_path):
"""Validate that an output path can be written."""
output_dir = os.path.dirname(os.path.abspath(output_path)) or "."
if not os.path.isdir(output_dir):
raise ValueError(f"Output directory does not exist: {output_dir}")
def validate_prompt(prompt):
"""Validate a text-to-3D prompt."""
if not isinstance(prompt, str) or not prompt.strip():
raise ValueError("prompt must be non-empty text")
return prompt.strip()
def validate_style(style):
"""Validate an optional text-to-3D style."""
if style is None:
return None
if style not in SUPPORTED_STYLES:
supported = ", ".join(sorted(SUPPORTED_STYLES))
raise ValueError(f"Unsupported style '{style}'. Supported styles: {supported}")
return style
def validate_boolean(value, name):
"""Validate an optional boolean field."""
if value is None:
return None
if not isinstance(value, bool):
raise ValueError(f"{name} must be a boolean")
return value
def validate_version(version):
"""Validate the required image/text generation version."""
if version not in GENERATION_VERSIONS:
raise ValueError("version is required and must be 'G1' or 'G1-Turbo'")
return version
def validate_material_version(version):
"""Validate the required material-transfer version."""
if version != MATERIAL_VERSION:
raise ValueError(f"version is required and must be '{MATERIAL_VERSION}'")
return version
def validate_face_count(faceCount):
"""Validate the target Mesh face count."""
if faceCount is None:
return None
if not isinstance(faceCount, int) or isinstance(faceCount, bool):
raise ValueError("faceCount must be an integer")
if not FACE_COUNT_MIN <= faceCount <= FACE_COUNT_MAX:
raise ValueError(
f"faceCount must be in [{FACE_COUNT_MIN}, {FACE_COUNT_MAX}]"
)
return faceCount
def normalize_output_formats(outputFormat):
"""Normalize outputFormat while preserving order and rejecting duplicates."""
if outputFormat is None:
return None
if isinstance(outputFormat, str):
formats = [outputFormat]
elif isinstance(outputFormat, (list, tuple)):
formats = list(outputFormat)
else:
raise ValueError("outputFormat must be a string or an ordered list of strings")
if any(not isinstance(item, str) for item in formats):
raise ValueError("outputFormat values must be strings")
if len(formats) != len(set(formats)):
raise ValueError("outputFormat must not contain duplicate values")
return formats
def validate_allowed_formats(outputFormat, allowed, label):
"""Validate outputFormat against one endpoint's public values."""
formats = normalize_output_formats(outputFormat)
invalid = [item for item in formats or [] if item not in allowed]
if invalid:
supported = ", ".join(sorted(allowed))
raise ValueError(f"{label} supports only: {supported}")
return formats
def validate_generation_options(
version, outputFormat=None, enablePbr=None, customSize=None
):
"""Validate version-specific image/text generation fields."""
version = validate_version(version)
formats = validate_allowed_formats(
outputFormat, GENERATION_FORMATS, f"{version} generation"
)
validate_boolean(enablePbr, "enablePbr")
validate_custom_size(customSize)
if version == "G1" and enablePbr is not None:
raise ValueError("enablePbr is not a G1 field; omit it for G1")
if version == "G1-Turbo" and customSize is not None:
raise ValueError("customSize is not a G1-Turbo field; omit it for G1-Turbo")
if (
version == "G1-Turbo"
and formats == ["ply"]
and enablePbr is not None
):
raise ValueError("enablePbr must be omitted for a G1-Turbo PLY-only request")
return formats
def validate_custom_size(customSize):
"""Validate a model height in millimetres."""
if customSize is None:
return None
if isinstance(customSize, bool) or not isinstance(customSize, (int, float)):
raise ValueError("customSize must be a number greater than 0")
if customSize <= 0:
raise ValueError("customSize must be a number greater than 0")
return customSize
def validate_material_options(
version, outputFormat=None, aiPredictSize=None, customSize=None
):
"""Validate material-transfer fields for v3.0-standard."""
validate_material_version(version)
formats = validate_allowed_formats(
outputFormat, MATERIAL_FORMATS, "Material transfer"
)
validate_boolean(aiPredictSize, "aiPredictSize")
validate_custom_size(customSize)
if aiPredictSize is True and customSize is not None:
raise ValueError("customSize must be omitted when aiPredictSize is true")
return formats
def validate_export_options(modelUrl, outputFormat=None, region=None):
"""Validate multi-format export input and requested formats."""
model_url = validate_url_suffix(
modelUrl, "modelUrl", (".zip", ".glb"), region
)
formats = validate_allowed_formats(
outputFormat, EXPORT_FORMATS, "Multi-format export"
)
path = urllib.parse.unquote(urllib.parse.urlparse(model_url).path).lower()
if path.endswith(".glb") and not formats:
raise ValueError(
"outputFormat is required for GLB input and must include at least "
"one of: usdz, obj_zip, fbx_zip, stl, 3mf"
)
return model_url, formats
def add_optional(payload, name, value):
"""Add a field only when the caller supplied it."""
if value is not None:
payload[name] = value
def build_multimodal_payload(img=None, prompt=None, region=None):
"""Build a payload that requires at least one image or text input."""
if img is None and prompt is None:
raise ValueError("Pass at least one of img or prompt")
payload = {}
if img is not None:
payload["img"] = validate_http_url(img, "img", region)
if prompt is not None:
payload["prompt"] = validate_prompt(prompt)
return payload
def generation_result_formats(version, outputFormat=None):
"""Return the documented output order for a generation request."""
formats = normalize_output_formats(outputFormat)
if version == "G1":
result = ["zip", "glb"]
if formats and "ply" in formats:
result.append("ply")
return result
return formats or ["zip"]
def material_result_formats(outputFormat=None):
"""Return the documented material-transfer output order."""
formats = normalize_output_formats(outputFormat) or []
return ["zip", "glb"] + [
item for item in MATERIAL_OUTPUT_SLOTS[2:] if item in formats
]
def export_result_formats(modelUrl, outputFormat=None):
"""Return expected output formats for a multi-format export request."""
formats = normalize_output_formats(outputFormat) or []
result = [item for item in EXPORT_OUTPUT_SLOTS[2:] if item in formats]
path = urllib.parse.unquote(urllib.parse.urlparse(modelUrl).path).lower()
if path.endswith(".zip"):
result.insert(0, "glb")
return result
def output_extension(output_format):
"""Return the file extension for a public output format."""
if output_format in {"obj_zip", "fbx_zip", "zip"}:
return "zip"
return output_format
def get_auth_headers(region):
"""Build the Lux3D authentication headers."""
return {
"Content-Type": "application/json",
"Authorization": validate_api_key(region),
}
def secure_request(
method,
url,
headers=None,
data=None,
timeout=None,
retries=None,
stream=False,
):
"""Perform an HTTP request with bounded retries."""
method = method.upper()
request_headers = {"Content-Type": "application/json"} if headers is None else headers
timeout = REQUEST_TIMEOUT if timeout is None else timeout
retries = (1 if method == "POST" else MAX_RETRIES) if retries is None else retries
if not isinstance(retries, int) or isinstance(retries, bool) or retries < 1:
raise ValueError("retries must be a positive integer")
last_error = None
for attempt in range(retries):
try:
response = requests.request(
method=method,
url=url,
headers=request_headers,
json=data,
timeout=timeout,
stream=stream,
)
response.raise_for_status()
return response
except requests.exceptions.Timeout:
last_error = f"Request timeout (attempt {attempt + 1}/{retries})"
except requests.exceptions.RequestException as exc:
last_error = f"Request failed: {exc}"
if attempt < retries - 1:
time.sleep(RETRY_DELAY)
raise RuntimeError(f"Request failed after {retries} attempts: {last_error}")
def ensure_success(result):
"""Validate create, query, and list response envelopes."""
if not isinstance(result, dict):
raise ApiProtocolError("API response must be a JSON object")
required = {"f", "c", "m", "d"}
missing = sorted(required - set(result))
if missing:
raise ApiProtocolError(
"API response is missing envelope fields: " + ", ".join(missing)
)
code = result["c"]
if code == "0":
if result["f"] is not None:
raise ApiProtocolError("Successful API response f must be null")
if result["m"] != "":
raise ApiProtocolError("Successful API response m must be an empty string")
return result
if isinstance(code, str) and code:
raise ApiBusinessRejection(code, result.get("m"), result)
raise ApiProtocolError(
f"API response must contain string success code '0'; got {code!r}"
)
def submit_task(path, payload, base_url=None, region=None):
"""Submit an asynchronous task and return its task ID."""
request_region = resolve_request_region(base_url, region)
url = normalize_base_url(base_url, request_region) + path
response = secure_request(
"POST", url, headers=get_auth_headers(request_region), data=payload
)
try:
result = response.json()
except ValueError as exc:
raise ApiProtocolError(f"Invalid JSON response: {response.text}") from exc
ensure_success(result)
task_id = result["d"]
if not isinstance(task_id, int) or isinstance(task_id, bool) or task_id <= 0:
raise ApiProtocolError(
f"Create response d must be a positive long task ID: {result}"
)
return str(task_id)
def create_image_to_3d_task(
img=None,
*,
imgs=None,
version,
faceCount=None,
outputFormat=None,
enablePbr=None,
aiPredictSize=None,
customSize=None,
base_url=None,
region=None,
):
"""Create an image-to-3D task from one URL or 1-32 URL inputs."""
if (img is None) == (imgs is None):
raise ValueError("Pass exactly one of img or imgs")
payload = {}
if img is not None:
payload["img"] = validate_http_url(img, "img", region)
else:
if not isinstance(imgs, (list, tuple)):
raise ValueError("imgs must be an ordered list of 1-32 image URLs")
if not 1 <= len(imgs) <= 32:
raise ValueError("imgs must contain 1-32 image URLs")
payload["imgs"] = [
validate_http_url(item, f"imgs[{index}]", region)
for index, item in enumerate(imgs)
]
payload["version"] = validate_version(version)
formats = validate_generation_options(
version, outputFormat, enablePbr, customSize
)
add_optional(payload, "faceCount", validate_face_count(faceCount))
if outputFormat is not None:
payload["outputFormat"] = formats
add_optional(payload, "enablePbr", enablePbr)
add_optional(
payload, "aiPredictSize", validate_boolean(aiPredictSize, "aiPredictSize")
)
add_optional(payload, "customSize", validate_custom_size(customSize))
return submit_task(
"/lux3d/v1/generate/img-to-3d/task/create",
payload,
base_url=base_url,
region=region,
)
def create_task(img=None, **kwargs):
"""Backward-compatible name for create_image_to_3d_task."""
return create_image_to_3d_task(img, **kwargs)
def create_text_to_3d_task(
prompt,
*,
version,
style=None,
img=None,
faceCount=None,
outputFormat=None,
enablePbr=None,
aiPredictSize=None,
customSize=None,
base_url=None,
region=None,
):
"""Create a text-to-3D task with one optional reference-image URL."""
payload = {
"prompt": validate_prompt(prompt),
"version": validate_version(version),
}
add_optional(payload, "style", validate_style(style))
if img is not None:
payload["img"] = validate_http_url(img, "img", region)
formats = validate_generation_options(
version, outputFormat, enablePbr, customSize
)
add_optional(payload, "faceCount", validate_face_count(faceCount))
if outputFormat is not None:
payload["outputFormat"] = formats
add_optional(payload, "enablePbr", enablePbr)
add_optional(
payload, "aiPredictSize", validate_boolean(aiPredictSize, "aiPredictSize")
)
add_optional(payload, "customSize", validate_custom_size(customSize))
return submit_task(
"/lux3d/v1/generate/text-to-3d/task/create",
payload,
base_url=base_url,
region=region,
)
def create_material_transfer_task(
img,
meshUrl,
*,
version,
outputFormat=None,
aiPredictSize=None,
customSize=None,
base_url=None,
region=None,
):
"""Create a v3.0-standard material-transfer task."""
formats = validate_material_options(
version, outputFormat, aiPredictSize, customSize
)
payload = {
"img": validate_http_url(img, "img", region),
"meshUrl": validate_url_suffix(meshUrl, "meshUrl", (".glb",), region),
"version": validate_material_version(version),
}
if outputFormat is not None:
payload["outputFormat"] = formats
add_optional(
payload, "aiPredictSize", validate_boolean(aiPredictSize, "aiPredictSize")
)
add_optional(payload, "customSize", validate_custom_size(customSize))
return submit_task(
"/lux3d/v1/generate/material-transfer/task/create",
payload,
base_url=base_url,
region=region,
)
def create_multimodal_to_image_task(
img=None, *, prompt=None, base_url=None, region=None
):
"""Create one image from an image URL, text, or both."""
payload = build_multimodal_payload(img, prompt, region)
return submit_task(
"/lux3d/v1/generate/multimodal-to-image/task/create",
payload,
base_url=base_url,
region=region,
)
def create_image_to_four_view_task(
img=None, *, prompt=None, base_url=None, region=None
):
"""Create a four-view task from an image URL, text, or both."""
payload = build_multimodal_payload(img, prompt, region)
return submit_task(
"/lux3d/v1/generate/image-to-four-view/task/create",
payload,
base_url=base_url,
region=region,
)
def create_multi_format_export_task(
modelUrl,
*,
outputFormat=None,
base_url=None,
region=None,
):
"""Create a multi-format export task for a Lux3D ZIP or GLB URL."""
model_url, formats = validate_export_options(
modelUrl, outputFormat, region
)
payload = {"modelUrl": model_url}
if outputFormat is not None:
payload["outputFormat"] = formats
return submit_task(
"/lux3d/v1/multi-format-export/task/create",
payload,
base_url=base_url,
region=region,
)
def get_task(task_id, base_url=None, region=None):
"""Query one Lux3D task once and return the public task object."""
if isinstance(task_id, int) and not isinstance(task_id, bool):
requested_task_id = task_id
elif isinstance(task_id, str) and task_id.strip().isdigit():
requested_task_id = int(task_id.strip())
else:
raise ValueError("task_id must be a positive long")
if requested_task_id <= 0:
raise ValueError("task_id must be a positive long")
request_region = resolve_request_region(base_url, region)
url = (
normalize_base_url(base_url, request_region)
+ "/lux3d/v1/generate/task/get?taskid="
+ urllib.parse.quote(str(requested_task_id))
)
response = secure_request(
"GET", url, headers=get_auth_headers(request_region)
)
try:
result = response.json()
except ValueError as exc:
raise ApiProtocolError(f"Invalid JSON response: {response.text}") from exc
ensure_success(result)
task_data = result["d"]
if not isinstance(task_data, dict):
raise ApiProtocolError(f"Task response d must be a JSON object: {result}")
required = {"bizId", "taskId", "status"}
missing = sorted(required - set(task_data))
if missing:
raise ApiProtocolError(
"Task response d is missing fields: " + ", ".join(missing)
)
if task_data["bizId"] != "LUX_3D":
raise ApiProtocolError("Task response bizId must be LUX_3D")
response_task_id = task_data["taskId"]
if (
not isinstance(response_task_id, int)
or isinstance(response_task_id, bool)
or response_task_id <= 0
):
raise ApiProtocolError("Task response taskId must be a positive long")
if response_task_id != requested_task_id:
raise ApiProtocolError(
"Task response taskId does not match the requested task_id"
)
status = task_data["status"]
if (
not isinstance(status, int)
or isinstance(status, bool)
or status not in SUPPORTED_TASK_STATUSES
):
raise ApiProtocolError("Task response status must be one of 0, 1, 3, 4, or 6")
return task_data
def infer_artifact_format(url):
"""Infer a public artifact format from a result URL when possible."""
path = urllib.parse.unquote(urllib.parse.urlparse(url).path).lower()
filename = path.rsplit("/", 1)[-1]
if filename.endswith(("_obj.zip", ".obj.zip", "obj.zip")):
return "obj_zip"
if filename.endswith(("_fbx.zip", ".fbx.zip", "fbx.zip")):
return "fbx_zip"
if filename.endswith(".usdz"):
return "usdz"
if filename.endswith(".stl"):
return "stl"
if filename.endswith(".3mf"):
return "3mf"
if filename.endswith(".glb"):
return "glb"
if filename.endswith(".ply"):
return "ply"
if filename.endswith(".zip"):
return "zip"
return None
def normalize_output_content(output):
"""Return one usable output value, or None for a documented empty slot."""
if not isinstance(output, dict):
raise RuntimeError("Task output item must be a JSON object")
if "content" not in output:
return None
content = output["content"]
if not isinstance(content, str):
raise RuntimeError("Task output content must be a string when present")
content = content.strip()
if not content or content.lower() in {"not_requested", "null"}:
return None
return content
def parse_fixed_output_slots(task_data, output_slots):
"""Map fixed-position task outputs without guessing from URL suffixes."""
outputs = task_data.get("outputs") or []
slots = tuple(output_slots)
if len(outputs) != len(slots):
raise RuntimeError(
f"Expected {len(slots)} fixed output slots, got {len(outputs)}: "
f"{task_data}"
)
artifacts = {}
for slot, output in zip(slots, outputs):
content = normalize_output_content(output)
if content is not None:
artifacts[slot] = content
if not artifacts:
raise RuntimeError(f"Task succeeded without output content: {task_data}")
return artifacts
def parse_task_outputs(task_data):
"""Parse result URLs without discarding their documented order."""
contents = []
for output in task_data.get("outputs") or []:
content = normalize_output_content(output)
if content is not None:
contents.append(content)
if not contents:
raise RuntimeError(f"Task succeeded without output content: {task_data}")
if len(contents) == 1 and isinstance(contents[0], str):
try:
view_urls = json.loads(contents[0])
except (TypeError, ValueError):
view_urls = None
if (
isinstance(view_urls, list)
and len(view_urls) == 4
and all(isinstance(item, str) for item in view_urls)
):
return [
validate_http_url(item, f"fourView[{index}]")
for index, item in enumerate(view_urls)
]
artifacts = {}
for content in contents:
if not isinstance(content, str):
break
output_format = infer_artifact_format(content)
if output_format is None or output_format in artifacts:
break
artifacts[output_format] = content
else:
return artifacts
return contents[0] if len(contents) == 1 else contents
def query_task_status(
task_id,
base_url=None,
region=None,
max_attempts=DEFAULT_POLL_ATTEMPTS,
interval=DEFAULT_POLL_INTERVAL,
output_slots=None,
):
"""Poll a task until it succeeds, fails, or is cancelled."""
if not isinstance(max_attempts, int) or max_attempts < 1:
raise ValueError("max_attempts must be a positive integer")
if not isinstance(interval, (int, float)) or interval < 0:
raise ValueError("interval must be a non-negative number")
last_provider_status = None
for attempt in range(max_attempts):
try:
task_data = get_task(task_id, base_url=base_url, region=region)
except RuntimeError as exc:
raise TaskQueryError(
f"Task query failed: {exc}",
last_provider_status=last_provider_status,
) from exc
status = task_data.get("status")
if status == 3:
try:
if output_slots is not None:
return parse_fixed_output_slots(task_data, output_slots)
return parse_task_outputs(task_data)
except (RuntimeError, ValueError) as exc:
raise TaskOutputError(
f"Task succeeded but output contract validation failed: {exc}",
task_data=task_data,
) from exc
if status == 4:
raise RuntimeError(f"Task failed: {task_data}")
if status == 6:
raise RuntimeError(f"Task was cancelled: {task_data}")
if status not in {0, 1}:
raise TaskQueryError(
f"Unknown task status: {status}",
last_provider_status=last_provider_status,
)
last_provider_status = status
if attempt < max_attempts - 1:
time.sleep(interval)
raise TaskPollingTimeout(
"Task did not finish before the polling limit",
last_provider_status=last_provider_status,
)
def validate_task_list_data(data):
"""Validate the documented list response instead of synthesizing history."""
if not isinstance(data, dict):
raise ApiProtocolError("Task list response d must be a JSON object")
required = {"items", "total", "page", "pageSize"}
missing = sorted(required - set(data))
if missing:
raise ApiProtocolError(
"Task list response d is missing fields: " + ", ".join(missing)
)
items = data["items"]
if not isinstance(items, list) or any(not isinstance(item, dict) for item in items):
raise ApiProtocolError("Task list response items must be a list of objects")
for name, minimum, maximum in (
("total", 0, None),
("page", 1, None),
("pageSize", 1, 100),
):
value = data[name]
if (
not isinstance(value, int)
or isinstance(value, bool)
or value < minimum
or (maximum is not None and value > maximum)
):
expected = f">= {minimum}" if maximum is None else f"in [{minimum}, {maximum}]"
raise ApiProtocolError(f"Task list response {name} must be an integer {expected}")
if data["total"] < len(items):
raise ApiProtocolError("Task list response total cannot be smaller than items")
for index, item in enumerate(items):
missing_item_fields = {
"taskId",
"status",
"created",
"lastModified",
} - set(item)
if missing_item_fields:
raise ApiProtocolError(
f"Task list item {index} is missing fields: "
+ ", ".join(sorted(missing_item_fields))
)
if (
not isinstance(item["status"], int)
or isinstance(item["status"], bool)
or item["status"] not in SUPPORTED_TASK_STATUSES
):
raise ApiProtocolError(
f"Task list item {index} has unsupported status: {item['status']!r}"
)
for name in ("taskId", "created", "lastModified"):
value = item[name]
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
raise ApiProtocolError(
f"Task list item {index} {name} must be a non-negative integer"
)
return data
def list_tasks(
page=1,
pagesize=20,
status=None,
starttime=None,
endtime=None,
base_url=None,
region=None,
):
"""List tasks for the current API-key account."""
if not isinstance(page, int) or isinstance(page, bool) or page < 1:
raise ValueError("page must be a positive integer")
if (
not isinstance(pagesize, int)
or isinstance(pagesize, bool)
or not 1 <= pagesize <= 100
):
raise ValueError("pagesize must be an integer between 1 and 100")
if (
status is not None
and (
not isinstance(status, int)
or isinstance(status, bool)
or status not in SUPPORTED_TASK_STATUSES
)
):
raise ValueError("status must be one of 0, 1, 3, 4, or 6")
for name, value in (("starttime", starttime), ("endtime", endtime)):
if (
value is not None
and (
not isinstance(value, int)
or isinstance(value, bool)
or value < 0
)
):
raise ValueError(
f"{name} must be a non-negative Unix timestamp in milliseconds"
)
if starttime is not None and endtime is not None and starttime >= endtime:
raise ValueError("starttime must be earlier than endtime")
params = {"page": page, "pagesize": pagesize}
for name, value in (
("status", status),
("starttime", starttime),
("endtime", endtime),
):
if value is not None:
params[name] = value
request_region = resolve_request_region(base_url, region)
url = (
normalize_base_url(base_url, request_region)
+ "/lux3d/v1/generate/task/list?"
+ urllib.parse.urlencode(params)
)
response = secure_request(
"GET", url, headers=get_auth_headers(request_region)
)
try:
result = response.json()
except ValueError as exc:
raise ApiProtocolError(f"Invalid JSON response: {response.text}") from exc
ensure_success(result)
return validate_task_list_data(result.get("d"))
def download_model(model_url, output_path):
"""Download one result URL and return the number of bytes written."""
model_url = validate_http_url(model_url, "model_url")
validate_output_path(output_path)
response = secure_request("GET", model_url, headers={}, stream=True)
total_size = 0
with open(output_path, "wb") as file_obj:
for chunk in response.iter_content(chunk_size=DOWNLOAD_CHUNK_SIZE):
if chunk:
file_obj.write(chunk)
total_size += len(chunk)
response.close()
return total_size
def map_artifact_urls(outputs, expected_formats):
"""Map ordered or named task outputs to expected formats."""
if isinstance(outputs, dict):
missing = [item for item in expected_formats if item not in outputs]
if missing:
raise ValueError(
"Missing expected result formats: " + ", ".join(missing)
)
return {item: outputs[item] for item in expected_formats}
if isinstance(outputs, str) and len(expected_formats) == 1:
return {expected_formats[0]: outputs}
if (
isinstance(outputs, list)
and len(outputs) == len(expected_formats)
and all(isinstance(item, str) for item in outputs)
):
return dict(zip(expected_formats, outputs))
raise ValueError("Task outputs do not match the expected result formats")
def download_requested_models(outputs, output_path, expected_formats):
"""Download all expected model artifacts in documented output order."""
artifact_urls = map_artifact_urls(outputs, expected_formats)
if len(expected_formats) == 1:
output_format = expected_formats[0]
path = output_path
if not os.path.splitext(path)[1]:
path = f"{path}.{output_extension(output_format)}"
return [(path, download_model(artifact_urls[output_format], path))]
output_base, _ = os.path.splitext(output_path)
downloads = []
for output_format in expected_formats:
path = f"{output_base}_{output_format}.{output_extension(output_format)}"
size = download_model(artifact_urls[output_format], path)
downloads.append((path, size))
return downloads
def complete_task(
task_id,
*,
output_path=None,
expected_formats=None,
base_url=None,
region=None,
max_attempts=DEFAULT_POLL_ATTEMPTS,
interval=DEFAULT_POLL_INTERVAL,
output_slots=None,
):
"""Wait for a task, optionally downloading all expected artifacts."""
outputs = query_task_status(
task_id,
base_url=base_url,
region=region,
max_attempts=max_attempts,
interval=interval,
output_slots=output_slots,
)
if output_path is None:
return outputs
if not expected_formats:
raise ValueError("expected_formats are required when downloading results")
return download_requested_models(outputs, output_path, expected_formats)
def generate_3d_model(
img=None,
output_path=None,
*,
imgs=None,
version,
faceCount=None,
outputFormat=None,
enablePbr=None,
aiPredictSize=None,
customSize=None,
base_url=None,
region=None,
max_attempts=DEFAULT_POLL_ATTEMPTS,
interval=DEFAULT_POLL_INTERVAL,
):
"""Run the image-to-3D create, poll, and optional download workflow."""
task_id = create_image_to_3d_task(
img,
imgs=imgs,
version=version,
faceCount=faceCount,
outputFormat=outputFormat,
enablePbr=enablePbr,
aiPredictSize=aiPredictSize,
customSize=customSize,
base_url=base_url,
region=region,
)
return complete_task(
task_id,
output_path=output_path,
expected_formats=generation_result_formats(version, outputFormat),
base_url=base_url,
region=region,
max_attempts=max_attempts,
interval=interval,
)
def generate_text_to_3d(
prompt,
output_path=None,
*,
version,
style=None,
img=None,
faceCount=None,
outputFormat=None,
enablePbr=None,
aiPredictSize=None,
customSize=None,
base_url=None,
region=None,
max_attempts=DEFAULT_POLL_ATTEMPTS,
interval=DEFAULT_POLL_INTERVAL,
):
"""Run the text-to-3D create, poll, and optional download workflow."""
task_id = create_text_to_3d_task(
prompt,
version=version,
style=style,
img=img,
faceCount=faceCount,
outputFormat=outputFormat,
enablePbr=enablePbr,
aiPredictSize=aiPredictSize,
customSize=customSize,
base_url=base_url,
region=region,
)
return complete_task(
task_id,
output_path=output_path,
expected_formats=generation_result_formats(version, outputFormat),
base_url=base_url,
region=region,
max_attempts=max_attempts,
interval=interval,
)
def generate_material_transfer(
img,
meshUrl,
output_path=None,
*,
version,
outputFormat=None,
aiPredictSize=None,
customSize=None,
base_url=None,
region=None,
max_attempts=DEFAULT_POLL_ATTEMPTS,
interval=DEFAULT_POLL_INTERVAL,
):
"""Run the material-transfer create, poll, and optional download workflow."""
task_id = create_material_transfer_task(
img,
meshUrl,
version=version,
outputFormat=outputFormat,
aiPredictSize=aiPredictSize,
customSize=customSize,
base_url=base_url,
region=region,
)
return complete_task(
task_id,
output_path=output_path,
expected_formats=material_result_formats(outputFormat),
base_url=base_url,
region=region,
max_attempts=max_attempts,
interval=interval,
output_slots=MATERIAL_OUTPUT_SLOTS,
)
def generate_multimodal_image(
img=None,
output_path=None,
*,
prompt=None,
base_url=None,
region=None,
max_attempts=DEFAULT_POLL_ATTEMPTS,
interval=DEFAULT_POLL_INTERVAL,
):
"""Run the multimodal single-image create and poll workflow."""
task_id = create_multimodal_to_image_task(
img, prompt=prompt, base_url=base_url, region=region
)
result = complete_task(
task_id,
base_url=base_url,
region=region,
max_attempts=max_attempts,
interval=interval,
)
if not isinstance(result, str):
raise RuntimeError("The multimodal image task did not return one image URL")
image_url = validate_http_url(result, "image result", region)
if output_path is None:
return image_url
return [(output_path, download_model(image_url, output_path))]
def generate_four_views(
img=None,
*,
prompt=None,
base_url=None,
region=None,
max_attempts=DEFAULT_POLL_ATTEMPTS,
interval=DEFAULT_POLL_INTERVAL,
):
"""Run the multimodal viewpoint-completion workflow and return four URLs."""
task_id = create_image_to_four_view_task(
img, prompt=prompt, base_url=base_url, region=region
)
result = complete_task(
task_id,
base_url=base_url,
region=region,
max_attempts=max_attempts,
interval=interval,
)
if not (
isinstance(result, list)
and len(result) == 4
and all(isinstance(item, str) for item in result)
):
raise RuntimeError("The four-view task did not return four image URLs")
return result
def generate_multi_format_export(
modelUrl,
output_path=None,
*,
outputFormat=None,
base_url=None,
region=None,
max_attempts=DEFAULT_POLL_ATTEMPTS,
interval=DEFAULT_POLL_INTERVAL,
):
"""Run the multi-format export create, poll, and optional download workflow."""
task_id = create_multi_format_export_task(
modelUrl,
outputFormat=outputFormat,
base_url=base_url,
region=region,
)
return complete_task(
task_id,
output_path=output_path,
expected_formats=export_result_formats(modelUrl, outputFormat),
base_url=base_url,
region=region,
max_attempts=max_attempts,
interval=interval,
output_slots=EXPORT_OUTPUT_SLOTS,
)
def add_poll_arguments(parser):
"""Add shared polling controls to a CLI subcommand."""
parser.add_argument(
"--max-attempts", type=int, default=DEFAULT_POLL_ATTEMPTS
)
parser.add_argument(
"--interval", type=float, default=DEFAULT_POLL_INTERVAL
)
def add_generation_arguments(parser):
"""Add shared version-specific image/text generation options."""
parser.add_argument(
"--version", required=True, choices=sorted(GENERATION_VERSIONS)
)
parser.add_argument(
"--face-count",
dest="faceCount",
type=int,
default=None,
help=f"Mesh face count in [{FACE_COUNT_MIN}, {FACE_COUNT_MAX}]",
)
parser.add_argument(
"--format",
dest="outputFormat",
action="append",
choices=sorted(GENERATION_FORMATS),
default=None,
help="Repeat to request multiple formats.",
)
pbr_group = parser.add_mutually_exclusive_group()
pbr_group.add_argument(
"--enable-pbr", dest="enablePbr", action="store_true"
)
pbr_group.add_argument(
"--no-pbr", dest="enablePbr", action="store_false"
)
parser.set_defaults(enablePbr=None)
size_group = parser.add_mutually_exclusive_group()
size_group.add_argument(
"--ai-predict-size", dest="aiPredictSize", action="store_true"
)
size_group.add_argument(
"--no-ai-predict-size", dest="aiPredictSize", action="store_false"
)
parser.set_defaults(aiPredictSize=None)
parser.add_argument(
"--custom-size",
dest="customSize",
type=float,
help="G1 target height in millimetres; omit for G1-Turbo.",
)
add_poll_arguments(parser)
def build_parser():
"""Build the public command-line interface."""
parser = argparse.ArgumentParser(
description="Lux3D client using accessible URL inputs."
)
parser.add_argument(
"--region",
"-r",
choices=["cn", "international"],
default=None,
help="API region. Default for this package: cn",
)
parser.add_argument(
"--base-url",
default=None,
help="Override the API root; LUX3D_BASE_URL is also supported.",
)
subparsers = parser.add_subparsers(dest="command")
image_parser = subparsers.add_parser(
"image", help="Generate 3D from one or more accessible image URLs."
)
image_parser.add_argument("img", help="Primary accessible image URL")
image_parser.add_argument(
"output_path", nargs="?", help="Optional output path or filename base"
)
image_parser.add_argument(
"--image-view",
dest="imageViews",
action="append",
default=None,
help="Additional accessible image URL; repeat up to 31 times.",
)
add_generation_arguments(image_parser)
text_parser = subparsers.add_parser(
"text", help="Generate 3D from text and an optional image URL."
)
text_parser.add_argument("prompt")
text_parser.add_argument(
"output_path", nargs="?", help="Optional output path or filename base"
)
text_parser.add_argument("--style", choices=sorted(SUPPORTED_STYLES))
text_parser.add_argument(
"--image", dest="img", help="Optional accessible reference-image URL"
)
add_generation_arguments(text_parser)
material_parser = subparsers.add_parser(
"material", help="Transfer materials using accessible image and GLB URLs."
)
material_parser.add_argument("img", help="Material reference image URL")
material_parser.add_argument(
"output_path", nargs="?", help="Optional output path or filename base"
)
material_parser.add_argument(
"--mesh-url", dest="meshUrl", required=True, help="Accessible GLB URL"
)
material_parser.add_argument(
"--version", required=True, choices=[MATERIAL_VERSION]
)
material_parser.add_argument(
"--format",
dest="outputFormat",
action="append",
choices=sorted(MATERIAL_FORMATS),
default=None,
)
material_size_group = material_parser.add_mutually_exclusive_group()
material_size_group.add_argument(
"--ai-predict-size", dest="aiPredictSize", action="store_true"
)
material_size_group.add_argument(
"--no-ai-predict-size", dest="aiPredictSize", action="store_false"
)
material_parser.set_defaults(aiPredictSize=None)
material_parser.add_argument("--custom-size", dest="customSize", type=float)
add_poll_arguments(material_parser)
four_view_parser = subparsers.add_parser(
"four-view", help="Generate four viewpoints from an image URL, text, or both."
)
four_view_parser.add_argument(
"img", nargs="?", help="Optional accessible object image URL"
)
four_view_parser.add_argument(
"--prompt", help="Optional non-empty object or viewpoint prompt"
)
add_poll_arguments(four_view_parser)
export_parser = subparsers.add_parser(
"export", help="Export a Lux3D ZIP or GLB URL to other formats."
)
export_parser.add_argument("modelUrl", help="Accessible .zip or .glb URL")
export_parser.add_argument(
"output_path", nargs="?", help="Optional output path or filename base"
)
export_parser.add_argument(
"--format",
dest="outputFormat",
action="append",
choices=sorted(EXPORT_FORMATS),
default=None,
)
add_poll_arguments(export_parser)
query_parser = subparsers.add_parser("query", help="Query one task once.")
query_parser.add_argument("task_id")
list_parser = subparsers.add_parser(
"list", help="List generation tasks for the current account."
)
list_parser.add_argument("--page", type=int, default=1)
list_parser.add_argument("--pagesize", type=int, default=20)
list_parser.add_argument(
"--status",
type=int,
choices=sorted(SUPPORTED_TASK_STATUSES),
default=None,
)
list_parser.add_argument("--starttime", type=int, default=None)
list_parser.add_argument("--endtime", type=int, default=None)
return parser
def print_result(result):
"""Print a CLI result without Python representation noise."""
if isinstance(result, (dict, list, tuple)):
print(json.dumps(result, ensure_ascii=False, indent=2))
else:
print(result)
def main():
"""Command-line entry point."""
parser = build_parser()
args = parser.parse_args()
if not args.command:
parser.print_help()
raise SystemExit(1)
common = {"base_url": args.base_url, "region": args.region}
if args.command == "image":
image_urls = [args.img] + list(args.imageViews or [])
image_input = (
{"img": image_urls[0]} if len(image_urls) == 1 else {"imgs": image_urls}
)
result = generate_3d_model(
output_path=args.output_path,
version=args.version,
faceCount=args.faceCount,
outputFormat=args.outputFormat,
enablePbr=args.enablePbr,
aiPredictSize=args.aiPredictSize,
customSize=args.customSize,
max_attempts=args.max_attempts,
interval=args.interval,
**image_input,
**common,
)
elif args.command == "text":
result = generate_text_to_3d(
args.prompt,
output_path=args.output_path,
version=args.version,
style=args.style,
img=args.img,
faceCount=args.faceCount,
outputFormat=args.outputFormat,
enablePbr=args.enablePbr,
aiPredictSize=args.aiPredictSize,
customSize=args.customSize,
max_attempts=args.max_attempts,
interval=args.interval,
**common,
)
elif args.command == "material":
result = generate_material_transfer(
args.img,
args.meshUrl,
output_path=args.output_path,
version=args.version,
outputFormat=args.outputFormat,
aiPredictSize=args.aiPredictSize,
customSize=args.customSize,
max_attempts=args.max_attempts,
interval=args.interval,
**common,
)
elif args.command == "four-view":
result = generate_four_views(
args.img,
prompt=args.prompt,
max_attempts=args.max_attempts,
interval=args.interval,
**common,
)
elif args.command == "export":
result = generate_multi_format_export(
args.modelUrl,
output_path=args.output_path,
outputFormat=args.outputFormat,
max_attempts=args.max_attempts,
interval=args.interval,
**common,
)
elif args.command == "query":
result = get_task(args.task_id, **common)
elif args.command == "list":
result = list_tasks(
page=args.page,
pagesize=args.pagesize,
status=args.status,
starttime=args.starttime,
endtime=args.endtime,
**common,
)
else:
parser.error(f"Unknown command: {args.command}")
return
print_result(result)
if __name__ == "__main__":
try:
main()
except (ValueError, RuntimeError, TimeoutError) as exc:
print(f"[ERROR] {exc}", file=sys.stderr)
raise SystemExit(1)
SHA-256: e49a763d30369b807514a1f39e2dd2afac1dd31c2dfe60f82ce7435fb427825f