278 lines
8.3 KiB
JavaScript
278 lines
8.3 KiB
JavaScript
import { createHash, createHmac } from 'node:crypto'
|
|
import path from 'node:path'
|
|
import { fetch as undiciFetch } from 'undici'
|
|
import { OUTBOUND_PROXY_AGENT, TRIPO_API_BASE, TRIPO_API_KEY, TRIPO_MODEL_VERSION, hasOutboundProxy } from '../config.mjs'
|
|
import { parseDataUrl, sanitizeFileName } from '../http-utils.mjs'
|
|
import { cacheRemoteModel, hasLocalModel, localModelUrl, shouldUseProxy } from '../model-store.mjs'
|
|
import { findFirstValue, findModelUrl, isSuccessStatus } from '../object-utils.mjs'
|
|
|
|
export function getTripoHealth() {
|
|
return {
|
|
configured: Boolean(TRIPO_API_KEY),
|
|
modelVersion: TRIPO_MODEL_VERSION,
|
|
}
|
|
}
|
|
|
|
export async function createTripoTask(payload) {
|
|
requireTripoKey()
|
|
const image = parseDataUrl(payload.imageDataUrl)
|
|
const fileName = sanitizeFileName(payload.fileName || `cell-reference.${image.ext}`)
|
|
const file = await uploadImageToTripo({ ...image, fileName })
|
|
const task = await createTripoImageTask({ file })
|
|
|
|
return {
|
|
provider: 'tripo',
|
|
taskId: task.taskId,
|
|
raw: task.raw,
|
|
}
|
|
}
|
|
|
|
export async function getTripoTask(taskId) {
|
|
if (!taskId) {
|
|
throw Object.assign(new Error('taskId is required.'), { status: 400 })
|
|
}
|
|
|
|
if (await hasLocalModel(taskId, 'glb')) {
|
|
return {
|
|
provider: 'tripo',
|
|
taskId,
|
|
status: 'success',
|
|
progress: 100,
|
|
modelUrl: localModelUrl(taskId, 'glb'),
|
|
rawModelUrl: '',
|
|
error: '',
|
|
raw: { cached: true },
|
|
}
|
|
}
|
|
|
|
requireTripoKey()
|
|
const raw = await tripoRequest(`/task/${encodeURIComponent(taskId)}`, { method: 'GET' })
|
|
const data = raw.data || raw
|
|
const status = data.status || data.task_status || data.state || 'unknown'
|
|
const rawModelUrl = findModelUrl(data)
|
|
let modelUrl = rawModelUrl ? `/api/3d/model?url=${encodeURIComponent(rawModelUrl)}` : ''
|
|
let cacheError = ''
|
|
|
|
if (rawModelUrl && isSuccessStatus(status)) {
|
|
try {
|
|
modelUrl = await cacheRemoteModel(taskId, rawModelUrl)
|
|
} catch (error) {
|
|
cacheError = error.message || 'Model cache failed.'
|
|
}
|
|
}
|
|
|
|
return {
|
|
provider: 'tripo',
|
|
taskId,
|
|
status,
|
|
progress: data.progress ?? data.percent ?? null,
|
|
modelUrl,
|
|
rawModelUrl,
|
|
error: data.error || cacheError || '',
|
|
raw,
|
|
}
|
|
}
|
|
|
|
function requireTripoKey() {
|
|
if (!TRIPO_API_KEY) {
|
|
const error = new Error('TRIPO_API_KEY is not configured on the backend.')
|
|
error.status = 500
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async function uploadImageToTripo({ buffer, mime, fileName }) {
|
|
const format = getTripoUploadFormat(fileName, mime)
|
|
const tokenResult = await tripoRequest('/upload/sts/token', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ format }),
|
|
})
|
|
const tokenData = tokenResult.data || tokenResult
|
|
const host = tokenData.s3_host
|
|
const bucket = tokenData.resource_bucket
|
|
const key = tokenData.resource_uri
|
|
|
|
if (!host || !bucket || !key || !tokenData.sts_ak || !tokenData.sts_sk || !tokenData.session_token) {
|
|
const error = new Error('Tripo STS upload token response is missing required fields.')
|
|
error.detail = sanitizeTripoRaw(tokenResult)
|
|
throw error
|
|
}
|
|
|
|
await uploadToTripoObjectStorage({
|
|
buffer,
|
|
mime,
|
|
host,
|
|
bucket,
|
|
key,
|
|
accessKeyId: tokenData.sts_ak,
|
|
secretAccessKey: tokenData.sts_sk,
|
|
sessionToken: tokenData.session_token,
|
|
})
|
|
|
|
return {
|
|
type: 'jpg',
|
|
object: {
|
|
bucket,
|
|
key,
|
|
},
|
|
}
|
|
}
|
|
|
|
async function createTripoImageTask({ file }) {
|
|
const payload = {
|
|
type: 'image_to_model',
|
|
model_version: TRIPO_MODEL_VERSION,
|
|
file,
|
|
texture: true,
|
|
pbr: true,
|
|
texture_quality: 'standard',
|
|
geometry_quality: 'standard',
|
|
enable_image_autofix: true,
|
|
}
|
|
|
|
try {
|
|
return normalizeTaskCreateResponse(await tripoRequest('/task', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
}))
|
|
} catch {
|
|
const minimalPayload = {
|
|
type: 'image_to_model',
|
|
file,
|
|
}
|
|
|
|
return normalizeTaskCreateResponse(await tripoRequest('/task', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(minimalPayload),
|
|
}))
|
|
}
|
|
}
|
|
|
|
function getTripoUploadFormat(fileName, mime) {
|
|
const ext = path.extname(fileName).replace('.', '').toLowerCase()
|
|
if (ext === 'png' || mime === 'image/png') return 'png'
|
|
if (ext === 'webp' || mime === 'image/webp') return 'webp'
|
|
return 'jpeg'
|
|
}
|
|
|
|
async function uploadToTripoObjectStorage({ buffer, mime, host, bucket, key, accessKeyId, secretAccessKey, sessionToken }) {
|
|
const region = getAwsRegionFromS3Host(host)
|
|
const amzDate = getAwsDate()
|
|
const date = amzDate.slice(0, 8)
|
|
const payloadHash = sha256Hex(buffer)
|
|
const canonicalUri = `/${bucket}/${encodeAwsPath(key)}`
|
|
const headers = {
|
|
'content-type': mime,
|
|
host,
|
|
'x-amz-content-sha256': payloadHash,
|
|
'x-amz-date': amzDate,
|
|
'x-amz-security-token': sessionToken,
|
|
}
|
|
const signedHeaderNames = Object.keys(headers).sort()
|
|
const signedHeaders = signedHeaderNames.join(';')
|
|
const canonicalHeaders = signedHeaderNames.map((name) => `${name}:${String(headers[name]).trim()}\n`).join('')
|
|
const canonicalRequest = ['PUT', canonicalUri, '', canonicalHeaders, signedHeaders, payloadHash].join('\n')
|
|
const credentialScope = `${date}/${region}/s3/aws4_request`
|
|
const stringToSign = ['AWS4-HMAC-SHA256', amzDate, credentialScope, sha256Hex(canonicalRequest)].join('\n')
|
|
const signingKey = hmac(hmac(hmac(hmac(`AWS4${secretAccessKey}`, date), region), 's3'), 'aws4_request')
|
|
const signature = createHmac('sha256', signingKey).update(stringToSign).digest('hex')
|
|
const fetchOptions = shouldUseProxy(`https://${host}`) && OUTBOUND_PROXY_AGENT ? { dispatcher: OUTBOUND_PROXY_AGENT } : {}
|
|
const response = await undiciFetch(`https://${host}${canonicalUri}`, {
|
|
method: 'PUT',
|
|
...fetchOptions,
|
|
headers: {
|
|
...headers,
|
|
authorization: `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`,
|
|
},
|
|
body: buffer,
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const detail = await response.text().catch(() => '')
|
|
const error = new Error(`Tripo object upload failed with ${response.status}.`)
|
|
error.status = response.status || 502
|
|
error.detail = detail.slice(0, 500)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
function normalizeTaskCreateResponse(raw) {
|
|
const taskId = findFirstValue(raw, ['task_id', 'taskId', 'id'])
|
|
if (!taskId) {
|
|
const error = new Error('Tripo task response did not include a task id.')
|
|
error.detail = raw
|
|
throw error
|
|
}
|
|
|
|
return { taskId, raw }
|
|
}
|
|
|
|
async function tripoRequest(requestPath, options = {}) {
|
|
let response
|
|
try {
|
|
response = await undiciFetch(`${TRIPO_API_BASE}${requestPath}`, {
|
|
...options,
|
|
...(OUTBOUND_PROXY_AGENT ? { dispatcher: OUTBOUND_PROXY_AGENT } : {}),
|
|
headers: {
|
|
Authorization: `Bearer ${TRIPO_API_KEY}`,
|
|
...(options.headers || {}),
|
|
},
|
|
})
|
|
} catch (error) {
|
|
const wrapped = new Error(`Tripo network request failed: ${error.message}`)
|
|
wrapped.detail = {
|
|
path: requestPath,
|
|
cause: error.cause?.message || error.cause?.code || '',
|
|
proxy: hasOutboundProxy(),
|
|
}
|
|
throw wrapped
|
|
}
|
|
const text = await response.text()
|
|
let data
|
|
try {
|
|
data = text ? JSON.parse(text) : {}
|
|
} catch {
|
|
data = { message: text || 'Non-JSON response from Tripo.' }
|
|
}
|
|
|
|
if (!response.ok || (typeof data.code === 'number' && data.code !== 0)) {
|
|
const error = new Error(data.message || data.error || `Tripo request failed with ${response.status}.`)
|
|
error.status = response.status || 502
|
|
error.detail = data
|
|
throw error
|
|
}
|
|
|
|
return data
|
|
}
|
|
|
|
function sanitizeTripoRaw(raw) {
|
|
if (!raw || typeof raw !== 'object') return raw
|
|
return JSON.parse(JSON.stringify(raw, (key, value) => {
|
|
if (['sts_ak', 'sts_sk', 'session_token'].includes(key)) return '[secret omitted]'
|
|
return value
|
|
}))
|
|
}
|
|
|
|
function getAwsRegionFromS3Host(host) {
|
|
return host.match(/s3[.-]([a-z0-9-]+)\./)?.[1] || 'us-west-2'
|
|
}
|
|
|
|
function getAwsDate(date = new Date()) {
|
|
return date.toISOString().replace(/[:-]|\.\d{3}/g, '')
|
|
}
|
|
|
|
function encodeAwsPath(value) {
|
|
return String(value).split('/').map((part) => encodeURIComponent(part)).join('/')
|
|
}
|
|
|
|
function sha256Hex(value) {
|
|
return createHash('sha256').update(value).digest('hex')
|
|
}
|
|
|
|
function hmac(key, value) {
|
|
return createHmac('sha256', key).update(value).digest()
|
|
}
|