Files
QUNHE-INC\yuyue c3730e75d5 fix: migrate international Lux3D API and Labs domains
Port GitLab changes b227541 and b9c2491. Preserve GitHub website links, publication metadata, and explicit legacy API origins.
2026-10-13 19:24:57 +08:00

720 lines
25 KiB
Python

"""
File upload tool - supports single file and multipart upload
Supports automatic token refresh and progress callback
"""
# upload.py
# Copyright 2026 Qunhe Tech, all rights reserved.
# Qunhe PROPRIETARY/CONFIDENTIAL, any form of usage is subject to approval.
import time
import json
import hashlib
import traceback
import logging
from pathlib import Path
from typing import Callable, Optional, Dict, Any, List, Tuple
from io import BytesIO
from concurrent.futures import ThreadPoolExecutor, as_completed
import requests
# Import signature related functions
from ..sso.sso_token import load_config, generate_sign_by_lux3d_code
logger = logging.getLogger("LuxRealEngine")
# OUS V2 API paths (see OUS V2 API documentation)
API_ENDPOINTS = {
"single_upload": "/ous/api/v2/single/upload",
"multipart_init": "/ous/api/v2/block/upload/init",
"multipart_upload": "/ous/api/v2/block/upload/part",
"upload_status": "/ous/api/v2/upload/status",
}
# V2 authentication header name
OUS_TOKEN_HEADER = "ous-token-v2"
class UploadError(Exception):
"""Base class for upload errors"""
def __init__(self, code: str, message: str, extra: Optional[Dict[str, Any]] = None,
cause: Optional[Exception] = None):
super().__init__(message)
self.code = str(code)
self.message = message
self.extra = extra or {}
self.cause = cause
if cause:
self.__cause__ = cause
def __str__(self):
base_msg = f"[{self.code}] {self.message}"
if self.cause:
base_msg += f"\nCause: {type(self.cause).__name__}: {str(self.cause)}"
return base_msg
def calculate_file_md5(file_path: Path) -> str:
"""Calculate file MD5 hash"""
md5_hash = hashlib.md5()
with file_path.open("rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
md5_hash.update(chunk)
return md5_hash.hexdigest()
def create_error(code: Any, message: Any, extra: Optional[Dict[str, Any]] = None,
cause: Optional[Exception] = None) -> UploadError:
"""Create standard error object"""
return UploadError(
str(code or "ServerError"),
str(message or "Unknown error"),
extra,
cause
)
def make_request(
method: str,
url: str,
headers: Optional[Dict[str, str]] = None,
data: Optional[Dict[str, Any]] = None,
files: Optional[Dict[str, Any]] = None,
params: Optional[Dict[str, Any]] = None,
timeout_ms: Optional[int] = None,
auto_refresh_token: bool = False,
refresh_token_fn: Optional[Callable[[], str]] = None
) -> Dict[str, Any]:
"""
Unified HTTP request wrapper
Supports automatic token refresh
"""
timeout = (timeout_ms / 1000.0) if timeout_ms else None
# Log request information (hide sensitive information)
safe_headers = {k: v for k, v in (headers or {}).items() if k.lower() not in ['authorization', 'ous-token-v2']}
logger.debug(f"HTTP Request: {method} {url}")
logger.debug(f"Headers: {safe_headers}")
if data and not files:
logger.debug(f"Data: {data}")
if params:
logger.debug(f"Params: {params}")
try:
response = requests.request(
method=method,
url=url,
headers=headers,
data=data,
files=files,
params=params,
timeout=timeout
)
# Log response status
logger.debug(f"Response Status: {response.status_code}")
# Handle 401 error - token expired (V2 uses ous-token-v2)
if response.status_code == 401 and auto_refresh_token and refresh_token_fn and headers:
logger.warning(f"Token expired (401), attempting refresh for {url}")
new_token = refresh_token_fn()
headers[OUS_TOKEN_HEADER] = new_token
response = requests.request(
method=method,
url=url,
headers=headers,
data=data,
files=files,
params=params,
timeout=timeout
)
logger.debug(f"Retry Response Status: {response.status_code}")
response.raise_for_status()
return response.json()
except requests.HTTPError as e:
# Detailed HTTP error logging
status_code = e.response.status_code if e.response else None
try:
error_data = e.response.json()
error_code = error_data.get("c", "Unknown")
error_msg = error_data.get("m", "Unknown error")
logger.error(f"HTTP Error [{status_code}]: {method} {url}")
logger.error(f"Error Code: {error_code}, Message: {error_msg}")
logger.error(f"Full Response: {json.dumps(error_data, ensure_ascii=False)}")
raise create_error(error_code, error_msg, extra={"status_code": status_code, "url": url, "method": method},
cause=e)
except json.JSONDecodeError:
response_text = e.response.text[:500] if e.response else str(e)
logger.error(f"HTTP Error [{status_code}]: {method} {url}")
logger.error(f"Response Text: {response_text}")
raise create_error("HTTPError", f"HTTP {status_code}: {str(e)}",
extra={"status_code": status_code, "url": url, "response_text": response_text}, cause=e)
except Exception as e:
logger.error(f"Request Error: {method} {url}")
logger.error(f"Error Type: {type(e).__name__}")
logger.error(f"Error Message: {str(e)}")
logger.error(f"Traceback: {traceback.format_exc()}")
raise create_error("RequestError", str(e), cause=e)
def fetch_token_and_domain(base_api_path: Optional[str] = None, guid: Optional[str] = None,
lux3d_api_key: Optional[str] = None) -> Tuple[str, str, int]:
"""
Get upload token, global domain and chunk threshold (OUS V2)
Token response directly contains blockSize, no need to call chunk rule interface again.
Args:
base_api_path: API base path (used to build token acquisition URL)
guid: Upload configuration GUID (bound during V2 Token generation, recommended to pass)
lux3d_api_key: Invitation code (optional), used for OpenAPI signature
Returns:
(ous_token, global_domain, block_size) tuple
"""
# Use /global/luxrealengine/upload/token/v2 (internal interface, not exposed externally)
url = f"{base_api_path}/global/luxrealengine/upload/token/v2"
# Add OpenAPI signature parameters
lux3d_code = load_config(lux3d_api_key=lux3d_api_key if lux3d_api_key else None)
if not lux3d_code.get("ak") or not lux3d_code.get("sk") or not lux3d_code.get("appuid"):
raise create_error("ConfigError",
"Missing ak/sk/appuid configuration, please provide invitation code or configure config.txt file")
code_with_sign = generate_sign_by_lux3d_code(lux3d_code)
appuid = code_with_sign["appuid"]
appkey = code_with_sign["appkey"]
sign = code_with_sign["sign"]
timestamp = code_with_sign["timestamp"]
# Uniformly build query parameters
params = []
if guid:
params.append(f"guid={guid}")
params.append(f"appuid={appuid}")
params.append(f"appkey={appkey}")
params.append(f"sign={sign}")
params.append(f"timestamp={timestamp}")
url += "?" + "&".join(params)
logger.info(f">>> Fetching upload token from: {url}")
logger.debug(f">>> Request params: guid={guid}, appuid={appuid}, timestamp={timestamp}")
headers = {
"Accept": "application/json"
}
try:
result = make_request("POST", url, headers=headers)
except Exception as e:
logger.error(f">>> Failed to fetch upload token")
logger.error(f">>> Base API Path: {base_api_path}")
logger.error(f">>> GUID: {guid}")
logger.error(f">>> Has lux3d_api_key: {bool(lux3d_api_key)}")
raise
if result.get("c") != "0":
error_code = result.get("c", "Unknown")
error_msg = result.get("m", "Unknown error")
logger.error(f">>> Token fetch failed: Code={error_code}, Message={error_msg}")
logger.error(f">>> Full response: {json.dumps(result, ensure_ascii=False)}")
raise create_error(error_code, error_msg, extra={"response": result})
# Print result
logger.info(f">>> upload token Result: {result}")
data = result.get("d") or {}
token = data.get("ousToken")
global_domain = data.get("globalDomain")
block_size = int(data.get("blockSize") or (5 * 1024 * 1024)) # Default 5MB, consistent with documentation
if not token or not global_domain:
logger.error(f">>> Invalid token response: token={'***' if token else None}, domain={global_domain}")
logger.error(f">>> Full response data: {json.dumps(data, ensure_ascii=False)}")
raise create_error("InvalidResponse", "Missing ousToken or globalDomain", extra={"response_data": data})
logger.info(f">>> Token fetched successfully: domain={global_domain}, block_size={block_size}")
return token, global_domain, block_size
_V1_TOKEN_PATHS = {
"https://api.aholo3d.cn": "/asset/v1/token",
"https://api.aholo.ai": "/global/asset/v1/token",
"https://api.aholo3d.com": "/global/asset/v1/token",
}
def fetch_token_and_domain_by_authorization(
base_api_path: str,
authorization_api_key: str,
) -> Tuple[str, str, int]:
"""Get OUS credentials from the regional v1 API using Authorization."""
token_path = _V1_TOKEN_PATHS.get(base_api_path)
if token_path is None:
supported = ", ".join(_V1_TOKEN_PATHS)
raise create_error(
"ConfigError",
f"Unsupported v1 upload base API path: {base_api_path!r}; "
f"expected one of: {supported}",
)
if not isinstance(authorization_api_key, str) or not authorization_api_key.strip():
raise create_error("ConfigError", "Authorization API key must not be empty")
url = f"{base_api_path}{token_path}"
result = make_request(
"GET",
url,
headers={
"Accept": "application/json",
"Authorization": authorization_api_key.strip(),
},
)
if not isinstance(result, dict):
raise create_error(
"InvalidResponse",
"V1 upload token response must be a JSON object",
)
token = result.get("ousToken")
global_domain = result.get("globalDomain")
block_size = int(result.get("blockSize") or (5 * 1024 * 1024))
if not token or not global_domain:
raise create_error(
"InvalidResponse",
"V1 upload token response is missing ousToken or globalDomain",
extra={"response_data": result},
)
return token, global_domain, block_size
def upload_single_file(
domain: str,
md5: str,
file_path: Path,
ous_token: str,
metadata: Optional[Dict[str, Any]] = None,
custom_prefix: Optional[str] = None,
custom_filename: Optional[str] = None,
auto_refresh_token: bool = False,
refresh_token_fn: Optional[Callable[[], str]] = None
) -> str:
"""
Single file upload (small files, OUS V2)
V2 doesn't pass guid in request, bound by Token.
Returns:
Task ID (taskId)
"""
url = f"{domain}{API_ENDPOINTS['single_upload']}"
with file_path.open("rb") as f:
files = {"file": (file_path.name, f)}
data = {"md5": md5}
if metadata:
data["metadata"] = json.dumps(metadata)
if custom_prefix:
data["customPrefix"] = custom_prefix
if custom_filename:
data["customFilename"] = custom_filename
result = make_request(
"POST", url,
headers={OUS_TOKEN_HEADER: ous_token},
data=data,
files=files,
auto_refresh_token=auto_refresh_token,
refresh_token_fn=refresh_token_fn
)
if result.get("c") == "0":
d = result.get("d") or {}
task_id = d.get("taskId") or d.get("obsTaskId")
if task_id:
return task_id
raise create_error(result.get("c"), result.get("m"))
def init_multipart_upload(
domain: str,
md5: str,
total_blocks: int,
file_size: int,
filename: str,
ous_token: str,
metadata: Optional[Dict[str, Any]] = None,
custom_prefix: Optional[str] = None,
custom_filename: Optional[str] = None,
auto_refresh_token: bool = False,
refresh_token_fn: Optional[Callable[[], str]] = None
) -> str:
"""
Initialize multipart upload (OUS V2)
V2 doesn't pass guid in request, bound by Token. Response returns taskId.
Returns:
Task ID (taskId)
"""
url = f"{domain}{API_ENDPOINTS['multipart_init']}"
data = {
"md5": md5,
"blocks": str(total_blocks),
"size": str(file_size),
"name": filename,
}
if metadata:
data["metadata"] = json.dumps(metadata)
if custom_prefix:
data["customPrefix"] = custom_prefix
if custom_filename:
data["customFilename"] = custom_filename
result = make_request(
"POST", url,
headers={OUS_TOKEN_HEADER: ous_token},
data=data,
auto_refresh_token=auto_refresh_token,
refresh_token_fn=refresh_token_fn
)
if result.get("c") == "0":
d = result.get("d") or {}
task_id = d.get("taskId") or d.get("obsTaskId")
if task_id:
return task_id
raise create_error(result.get("c"), result.get("m"))
def upload_file_part(
domain: str,
part_index: int,
part_data: bytes,
part_filename: str,
ous_token: str,
auto_refresh_token: bool = False,
refresh_token_fn: Optional[Callable[[], str]] = None
):
"""
Upload single chunk (OUS V2, from memory)
V2 doesn't pass guid/obstaskid, bound by Token; block is chunk index, starting from 1.
Args:
part_index: Chunk index (starting from 1)
part_data: Chunk binary data
part_filename: Chunk filename (for identification)
"""
url = f"{domain}{API_ENDPOINTS['multipart_upload']}"
files = {"file": (part_filename, BytesIO(part_data))}
data = {"block": str(part_index)}
result = make_request(
"POST", url,
headers={OUS_TOKEN_HEADER: ous_token},
data=data,
files=files,
auto_refresh_token=auto_refresh_token,
refresh_token_fn=refresh_token_fn
)
if result.get("c") != "0":
raise create_error(result.get("c"), result.get("m"))
def poll_upload_status(
domain: str,
ous_token: str,
interval_ms: int = 1000,
timeout_ms: int = 60000,
auto_refresh_token: bool = False,
refresh_token_fn: Optional[Callable[[], str]] = None
) -> Dict[str, Any]:
"""
Poll query upload status (OUS V2)
V2 doesn't need to pass taskId, Token already binds task. Polling interval recommended 200ms or above.
Status codes: 5=success, 6=failed, 8=manually aborted (all final states)
"""
url = f"{domain}{API_ENDPOINTS['upload_status']}"
headers = {
"Content-Type": "application/json",
OUS_TOKEN_HEADER: ous_token
}
start_time = time.time()
interval_sec = max(interval_ms / 1000.0, 0.2) # Documentation recommends 200ms or above
timeout_sec = timeout_ms / 1000.0
while True:
result = make_request(
"GET", url,
headers=headers,
auto_refresh_token=auto_refresh_token,
refresh_token_fn=refresh_token_fn
)
if result.get("c") != "0":
raise create_error(result.get("c"), result.get("m"))
data = result.get("d") or {}
status = int(data.get("status", 0))
# 5=success, 6=failed, 8=manually aborted (final states)
if status in (5, 6, 8):
return {
"c": result.get("c", "0"),
"m": result.get("m", ""),
"d": data
}
elapsed = time.time() - start_time
if elapsed >= timeout_sec:
raise create_error("QueryTimeout", f"Status query timeout ({timeout_ms}ms)")
time.sleep(interval_sec)
class FilePartition:
"""File chunk management (preload mode)"""
def __init__(self, file_path: Path, block_size: int):
self.file_path = file_path
self.block_size = block_size
self.file_size = file_path.stat().st_size
self.total_parts = (self.file_size + block_size - 1) // block_size
# Preload all chunks to memory
self.parts_data: List[bytes] = []
self._load_all_parts()
def _load_all_parts(self):
"""Load all chunks to memory at once"""
with self.file_path.open("rb") as f:
for i in range(self.total_parts):
start_offset = i * self.block_size
end_offset = min(start_offset + self.block_size, self.file_size)
read_size = end_offset - start_offset
f.seek(start_offset)
part_data = f.read(read_size)
self.parts_data.append(part_data)
def get_part_data(self, part_index: int) -> bytes:
"""
Get data of specified chunk (numbered from 1)
Args:
part_index: Chunk index (1-based)
Returns:
Binary data of the chunk
"""
if part_index < 1 or part_index > self.total_parts:
raise ValueError(f"Chunk index {part_index} out of range [1, {self.total_parts}]")
return self.parts_data[part_index - 1]
def get_part_size(self, part_index: int) -> int:
"""Get chunk size"""
if part_index < 1 or part_index > self.total_parts:
raise ValueError(f"Chunk index {part_index} out of range [1, {self.total_parts}]")
return len(self.parts_data[part_index - 1])
def upload_file(
guid: str,
file_path: Path,
base_api_path: Optional[str] = None,
refresh_token_fn: Optional[Callable[[], str]] = None,
chunk_parallel_limit: int = 2, # V2 recommends no more than 2 concurrent chunks for same file
chunk_retry_times: int = 3,
query_interval_ms: int = 1000,
query_timeout_ms: int = 60000,
metadata: Optional[Dict[str, Any]] = None,
on_progress: Optional[Callable[[int, int], None]] = None,
lux3d_api_key: Optional[str] = None,
authorization_api_key: Optional[str] = None,
) -> Dict[str, Any]:
"""
Main file upload function (OUS V2)
Process:
1. Get token, global domain and chunk threshold (token response contains blockSize, no need to call chunk rule interface)
2. Choose single file or multipart upload based on file size
3. Poll query upload status (V2 binds task by Token, no need to pass taskId)
Args:
guid: File unique identifier (only for server-side token generation, not sent to OUS with request)
file_path: File path
base_api_path: API base path (used to get token)
refresh_token_fn: Token refresh callback function
chunk_parallel_limit: Number of concurrent chunk uploads (V2 recommends no more than 2)
chunk_retry_times: Number of chunk upload retries
query_interval_ms: Status query interval (milliseconds, recommended >= 200)
query_timeout_ms: Status query timeout (milliseconds)
metadata: File metadata
on_progress: Progress callback (uploaded bytes, total bytes)
lux3d_api_key: Invitation code (optional), used for OpenAPI signature
authorization_api_key: V1 API key passed directly in Authorization
Returns:
Final upload status (including url, uploadKey, status, etc.)
"""
if not file_path.exists():
raise create_error("FileNotFound", f"File not found: {file_path}")
file_size = file_path.stat().st_size
if file_size == 0:
raise create_error("EmptyFile", "File size is 0")
logger.info(f">>> Starting upload for file: {file_path.name} (size: {file_size} bytes)")
# Step 1: Get token, global domain and chunk threshold (V2 returns once, no chunk rule interface needed)
try:
logger.info(f">>> Fetching upload token...")
if authorization_api_key is not None:
ous_token, global_domain, block_size = (
fetch_token_and_domain_by_authorization(
base_api_path,
authorization_api_key,
)
)
else:
ous_token, global_domain, block_size = fetch_token_and_domain(
base_api_path,
guid=guid,
lux3d_api_key=lux3d_api_key,
)
logger.info(f">>> Token fetched successfully")
except Exception as e:
logger.error(f">>> Failed to fetch upload token")
logger.error(f">>> File: {file_path}")
logger.error(f">>> Base API Path: {base_api_path}")
logger.error(f">>> GUID: {guid}")
raise
upload_domain = global_domain.rstrip("/")
file_md5 = calculate_file_md5(file_path)
auto_refresh = bool(refresh_token_fn)
# Step 2: Single file or multipart upload
if file_size <= block_size:
upload_single_file(
upload_domain, file_md5, file_path, ous_token,
metadata=metadata,
auto_refresh_token=auto_refresh,
refresh_token_fn=refresh_token_fn
)
if on_progress:
on_progress(file_size, file_size)
else:
_upload_file_in_parts(
file_path=file_path,
file_size=file_size,
file_md5=file_md5,
block_size=block_size,
upload_domain=upload_domain,
ous_token=ous_token,
metadata=metadata,
parallel_limit=chunk_parallel_limit,
retry_times=chunk_retry_times,
auto_refresh=auto_refresh,
refresh_token_fn=refresh_token_fn,
on_progress=on_progress
)
# Step 3: Poll status (V2 doesn't need taskId, Token already binds)
final_status = poll_upload_status(
upload_domain, ous_token,
interval_ms=query_interval_ms,
timeout_ms=query_timeout_ms,
auto_refresh_token=auto_refresh,
refresh_token_fn=refresh_token_fn
)
return final_status
def _upload_file_in_parts(
file_path: Path,
file_size: int,
file_md5: str,
block_size: int,
upload_domain: str,
ous_token: str,
metadata: Optional[Dict[str, Any]],
parallel_limit: int,
retry_times: int,
auto_refresh: bool,
refresh_token_fn: Optional[Callable[[], str]],
on_progress: Optional[Callable[[int, int], None]]
) -> None:
"""
Multipart upload implementation (OUS V2, internal function)
V2 chunk index starts from 1; Token binds task, no need to pass guid/taskId in part request.
"""
partition = FilePartition(file_path, block_size)
total_parts = partition.total_parts
init_multipart_upload(
upload_domain, file_md5,
total_parts, file_size, file_path.name,
ous_token,
metadata=metadata,
auto_refresh_token=auto_refresh,
refresh_token_fn=refresh_token_fn
)
uploaded_bytes = 0
upload_lock = __import__('threading').Lock()
def upload_single_part(part_index: int):
"""Upload single chunk (V2 block starts from 1, with retry)"""
nonlocal uploaded_bytes
part_size = partition.get_part_size(part_index)
part_filename = f"{file_path.name}.part-{part_index}"
for attempt in range(retry_times):
try:
part_data = partition.get_part_data(part_index)
upload_file_part(
upload_domain,
part_index,
part_data,
part_filename,
ous_token,
auto_refresh_token=auto_refresh,
refresh_token_fn=refresh_token_fn
)
with upload_lock:
uploaded_bytes += part_size
if on_progress:
on_progress(uploaded_bytes, file_size)
return
except Exception as e:
if attempt == retry_times - 1:
raise create_error(
"PartUploadFailed",
f"Chunk {part_index} upload failed: {str(e)}",
cause=e
)
time.sleep(0.5 * (attempt + 1))
with ThreadPoolExecutor(max_workers=parallel_limit) as executor:
futures = [
executor.submit(upload_single_part, i)
for i in range(1, total_parts + 1)
]
for future in as_completed(futures):
exc = future.exception()
if exc:
raise exc