chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:15:38 +08:00
commit 88025fdd5c
76 changed files with 18514 additions and 0 deletions
+61
View File
@@ -0,0 +1,61 @@
import { existsSync, readFileSync } from 'node:fs'
import path from 'node:path'
import { ProxyAgent } from 'undici'
loadLocalEnv()
export const API_PORT = Number(process.env.API_PORT || 8787)
export const API_HOST = process.env.API_HOST || '127.0.0.1'
export const BODY_LIMIT = 28 * 1024 * 1024
export const MODEL_UPLOAD_LIMIT = 180 * 1024 * 1024
export const TRIPO_API_KEY = process.env.TRIPO_API_KEY
export const TRIPO_API_BASE = process.env.TRIPO_API_BASE || 'https://api.tripo3d.ai/v2/openapi'
export const TRIPO_MODEL_VERSION = process.env.TRIPO_MODEL_VERSION || 'v3.0-20250812'
export const RODIN_API_KEY = process.env.RODIN_API_KEY
export const RODIN_API_BASE = process.env.RODIN_API_BASE || 'https://api.hyper3d.com/api/v2'
export const RODIN_TIER = process.env.RODIN_TIER || 'Gen-2'
export const RODIN_QUALITY = process.env.RODIN_QUALITY || 'medium'
export const RODIN_MESH_MODE = process.env.RODIN_MESH_MODE || 'Raw'
export const RODIN_MATERIAL = process.env.RODIN_MATERIAL || 'PBR'
export const HUNYUAN_API_BASE = process.env.HUNYUAN_API_BASE || 'http://127.0.0.1:8081'
export const HUNYUAN_CREATE_PATH = process.env.HUNYUAN_CREATE_PATH || '/send'
export const HUNYUAN_STATUS_PATH = process.env.HUNYUAN_STATUS_PATH || '/status'
export const FAL_API_KEY = process.env.FAL_API_KEY || process.env.FAL_KEY
export const FAL_DEFAULT_MODEL = process.env.FAL_DEFAULT_MODEL || 'fal-ai/hunyuan3d/v2'
export const VISION_PROVIDER = process.env.VISION_PROVIDER || 'openai'
export const OPENAI_API_KEY = process.env.OPENAI_API_KEY
export const OPENAI_API_BASE = process.env.OPENAI_API_BASE || 'https://api.openai.com/v1'
export const OPENAI_VISION_MODEL = process.env.OPENAI_VISION_MODEL || 'gpt-4o-mini'
export const LOCAL_MODEL_DIR = path.resolve(process.env.LOCAL_MODEL_DIR || '.generated-models')
export const LOG_DIR = path.resolve(process.env.LOG_DIR || '.logs')
export const LOG_FILE = path.resolve(LOG_DIR, process.env.LOG_FILE || '3d-model-studio-api.log')
export const OUTBOUND_PROXY_AGENT = createProxyAgent()
export function hasOutboundProxy() {
return Boolean(process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy)
}
function loadLocalEnv() {
if (!existsSync('.env.local')) return
const env = readFileSync('.env.local', 'utf8')
for (const line of env.split(/\r?\n/)) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
const index = trimmed.indexOf('=')
if (index === -1) continue
const key = trimmed.slice(0, index).trim()
let value = trimmed.slice(index + 1).trim()
value = value.replace(/^["']|["']$/g, '')
if (!process.env[key]) process.env[key] = value
}
}
function createProxyAgent() {
const proxy = process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy
if (!proxy) return null
return new ProxyAgent(proxy)
}
+124
View File
@@ -0,0 +1,124 @@
import { BODY_LIMIT, MODEL_UPLOAD_LIMIT } from './config.mjs'
export function setCorsHeaders(response) {
response.setHeader('Access-Control-Allow-Origin', process.env.CORS_ORIGIN || '*')
response.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS')
response.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization')
}
export function assertLocalDiagnosticsRequest(request) {
const remoteAddress = normalizeAddress(request.socket?.remoteAddress)
const origin = request.headers.origin
const referer = request.headers.referer
if (!isLocalHost(remoteAddress)) {
throw Object.assign(new Error('Diagnostics logs are only available from this machine.'), { status: 403 })
}
if (origin && !isLocalUrl(origin)) {
throw Object.assign(new Error('Diagnostics logs are only available to localhost pages.'), { status: 403 })
}
if (!origin && referer && !isLocalUrl(referer)) {
throw Object.assign(new Error('Diagnostics logs are only available to localhost pages.'), { status: 403 })
}
}
export function sendJson(response, status, payload) {
response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' })
response.end(JSON.stringify(payload))
}
export function readJsonBody(request) {
return new Promise((resolve, reject) => {
const chunks = []
let size = 0
request.on('data', (chunk) => {
size += chunk.length
if (size > BODY_LIMIT) {
reject(Object.assign(new Error('Image payload is too large.'), { status: 413 }))
request.destroy()
return
}
chunks.push(chunk)
})
request.on('end', () => {
try {
const raw = Buffer.concat(chunks).toString('utf8')
resolve(raw ? JSON.parse(raw) : {})
} catch {
reject(Object.assign(new Error('Invalid JSON payload.'), { status: 400 }))
}
})
request.on('error', reject)
})
}
export function readRawBody(request, limit = MODEL_UPLOAD_LIMIT) {
return new Promise((resolve, reject) => {
const chunks = []
let size = 0
request.on('data', (chunk) => {
size += chunk.length
if (size > limit) {
reject(Object.assign(new Error('Model payload is too large.'), { status: 413 }))
request.destroy()
return
}
chunks.push(chunk)
})
request.on('end', () => {
resolve(Buffer.concat(chunks))
})
request.on('error', reject)
})
}
export function parseDataUrl(dataUrl) {
if (typeof dataUrl !== 'string') {
throw Object.assign(new Error('imageDataUrl is required.'), { status: 400 })
}
const match = dataUrl.match(/^data:(image\/(?:png|jpe?g|webp));base64,(.+)$/)
if (!match) {
throw Object.assign(new Error('Only PNG, JPEG, or WebP image data URLs are supported.'), { status: 400 })
}
const mime = match[1]
const buffer = Buffer.from(match[2], 'base64')
const ext = mime.includes('png') ? 'png' : mime.includes('webp') ? 'webp' : 'jpg'
if (buffer.length < 1024) {
throw Object.assign(new Error('Image is too small for 3D generation.'), { status: 400 })
}
return { mime, buffer, ext }
}
export function sanitizeFileName(fileName) {
const baseName = String(fileName).split(/[\\/]/).pop() || ''
return baseName.replace(/[^\w.\- ]+/g, '').replace(/^\.+/, '').trim() || 'asset-reference.png'
}
function normalizeAddress(address = '') {
return String(address).replace(/^::ffff:/, '')
}
function isLocalHost(hostname = '') {
const normalized = String(hostname).toLowerCase()
return normalized === 'localhost' || normalized === '127.0.0.1' || normalized === '::1'
}
function isLocalUrl(value) {
try {
return isLocalHost(new URL(value).hostname)
} catch {
return false
}
}
+121
View File
@@ -0,0 +1,121 @@
import { appendFile, mkdir, readFile, stat } from 'node:fs/promises'
import { randomUUID } from 'node:crypto'
import path from 'node:path'
import { LOG_DIR, LOG_FILE } from './config.mjs'
const MAX_LOG_READ_BYTES = 768 * 1024
const SENSITIVE_KEYS = new Set([
'authorization',
'cookie',
'imageDataUrl',
'modelBase64',
'TRIPO_API_KEY',
'RODIN_API_KEY',
'FAL_API_KEY',
'OPENAI_API_KEY',
])
export function createRequestId() {
return randomUUID().slice(0, 12)
}
export async function logEvent(level, event, fields = {}) {
const entry = {
ts: new Date().toISOString(),
level,
event,
...sanitizeLogValue(fields),
}
try {
await mkdir(LOG_DIR, { recursive: true })
await appendFile(LOG_FILE, `${JSON.stringify(entry)}\n`, 'utf8')
} catch (error) {
console.warn('log write failed', error)
}
return entry
}
export async function readRecentLogs(limit = 100) {
try {
const fileStat = await stat(LOG_FILE)
const content = await readFile(LOG_FILE, 'utf8')
const slice = content.length > MAX_LOG_READ_BYTES ? content.slice(-MAX_LOG_READ_BYTES) : content
const lines = slice.trim().split(/\r?\n/).filter(Boolean)
const entries = lines.slice(-normalizeLimit(limit)).map(parseLogLine).filter(Boolean)
return {
file: path.relative(process.cwd(), LOG_FILE),
size: fileStat.size,
entries,
}
} catch {
return {
file: path.relative(process.cwd(), LOG_FILE),
size: 0,
entries: [],
}
}
}
export function summarizePayload(payload = {}) {
return {
provider: payload.provider,
modelId: payload.modelId,
fileName: payload.fileName,
hasImage: typeof payload.imageDataUrl === 'string',
imageBytes: estimateDataUrlBytes(payload.imageDataUrl),
promptChars: typeof payload.prompt === 'string' ? payload.prompt.length : 0,
}
}
export function summarizeError(error) {
if (!error) return {}
return {
message: error.message || 'Unknown error',
status: error.status,
detail: sanitizeLogValue(error.detail),
}
}
function normalizeLimit(limit) {
const value = Number(limit)
if (!Number.isFinite(value)) return 100
return Math.max(1, Math.min(500, Math.round(value)))
}
function parseLogLine(line) {
try {
return JSON.parse(line)
} catch {
return null
}
}
function estimateDataUrlBytes(value) {
if (typeof value !== 'string') return 0
const comma = value.indexOf(',')
const base64 = comma === -1 ? value : value.slice(comma + 1)
return Math.round((base64.length * 3) / 4)
}
function sanitizeLogValue(value, key = '') {
if (value === null || value === undefined) return value
if (SENSITIVE_KEYS.has(key)) return '[redacted]'
if (typeof value === 'string') {
if (value.startsWith('data:image/')) return `[image-data:${estimateDataUrlBytes(value)} bytes]`
if (value.length > 900) return `${value.slice(0, 900)}...`
return value
}
if (typeof value !== 'object') return value
if (Array.isArray(value)) return value.slice(0, 30).map((item) => sanitizeLogValue(item))
return Object.fromEntries(
Object.entries(value)
.slice(0, 80)
.map(([entryKey, entryValue]) => [entryKey, sanitizeLogValue(entryValue, entryKey)]),
)
}
+210
View File
@@ -0,0 +1,210 @@
import { createWriteStream } from 'node:fs'
import { access, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { Readable } from 'node:stream'
import { pipeline } from 'node:stream/promises'
import { fetch as undiciFetch } from 'undici'
import { LOCAL_MODEL_DIR, MODEL_UPLOAD_LIMIT, OUTBOUND_PROXY_AGENT, TRIPO_API_BASE, TRIPO_API_KEY } from './config.mjs'
import { readRawBody, sanitizeFileName } from './http-utils.mjs'
export async function saveLocalModel(taskId, modelData, ext = 'glb') {
const buffer = Buffer.isBuffer(modelData) ? modelData : parseModelBase64(modelData)
validateModelBuffer(buffer, ext)
await mkdir(LOCAL_MODEL_DIR, { recursive: true })
await writeFile(localModelPath(taskId, ext), buffer)
}
export async function hasLocalModel(taskId, ext = 'glb') {
try {
await access(localModelPath(taskId, ext))
return true
} catch {
return false
}
}
export function localModelPath(taskId, ext = 'glb') {
return path.join(LOCAL_MODEL_DIR, `${sanitizeModelId(taskId)}.${ext}`)
}
export function localModelUrl(taskId, ext = 'glb') {
return `/api/3d/local-model/${encodeURIComponent(sanitizeModelId(taskId))}.${ext}`
}
export async function serveLocalModel(url, response) {
const rawFileName = decodeURIComponent(url.pathname.replace('/api/3d/local-model/', ''))
const ext = getModelExtension(rawFileName)
const modelId = rawFileName.replace(/\.(?:glb|gltf)$/i, '')
const buffer = await readFile(localModelPath(modelId, ext))
response.writeHead(200, {
'Content-Type': ext === 'gltf' ? 'model/gltf+json' : 'model/gltf-binary',
'Cache-Control': 'private, max-age=3600',
})
response.end(buffer)
}
export async function importLocalModel(request, url) {
const fileName = sanitizeFileName(url.searchParams.get('fileName') || 'local-model.glb')
const ext = getModelExtension(fileName)
const buffer = await readRawBody(request, MODEL_UPLOAD_LIMIT)
validateModelBuffer(buffer, ext)
const baseName = fileName.replace(/\.(?:glb|gltf)$/i, '') || 'local-model'
const modelId = `local-${Date.now()}-${baseName}`
await saveLocalModel(modelId, buffer, ext)
return {
provider: 'local',
taskId: sanitizeModelId(modelId),
status: 'success',
progress: 100,
modelUrl: localModelUrl(modelId, ext),
rawModelUrl: '',
fileName,
}
}
export async function cacheRemoteModel(taskId, rawModelUrl) {
return cacheRemoteModelAs(taskId, rawModelUrl, getModelExtension(rawModelUrl))
}
export async function cacheRemoteModelAs(taskId, rawModelUrl, ext = 'glb') {
if (await hasLocalModel(taskId, ext)) return localModelUrl(taskId, ext)
await mkdir(LOCAL_MODEL_DIR, { recursive: true })
const targetPath = localModelPath(taskId, ext)
const tempPath = `${targetPath}.${Date.now()}.tmp`
try {
const remote = await fetchRemoteModel(rawModelUrl)
await pipeline(Readable.fromWeb(remote.body), createWriteStream(tempPath))
const buffer = await readFile(tempPath)
validateModelBuffer(buffer, ext)
await rename(tempPath, targetPath)
} catch (error) {
await rm(tempPath, { force: true }).catch(() => {})
throw error
}
return localModelUrl(taskId, ext)
}
export async function proxyModel(url, response) {
const rawUrl = url.searchParams.get('url')
if (!rawUrl || !isAllowedProxyModelUrl(rawUrl)) {
throw Object.assign(new Error('A valid HTTPS or localhost model URL is required.'), { status: 400 })
}
const fetchOptions = shouldUseProxy(rawUrl) && OUTBOUND_PROXY_AGENT ? { dispatcher: OUTBOUND_PROXY_AGENT } : {}
const remote = await undiciFetch(rawUrl, fetchOptions)
if (!remote.ok || !remote.body) {
const retry = await fetchWithTripoAuth(rawUrl, fetchOptions)
if (!retry.ok || !retry.body) {
throw Object.assign(new Error(`Model download failed with ${retry.status || remote.status}.`), { status: 502 })
}
await streamRemoteModel(retry, response)
return
}
await streamRemoteModel(remote, response)
}
export function getModelExtension(value) {
const pathname = /^https?:\/\//i.test(String(value)) ? new URL(value).pathname : String(value)
const ext = path.extname(pathname).replace('.', '').toLowerCase()
if (ext === 'gltf') return 'gltf'
if (ext === 'glb') return 'glb'
throw Object.assign(new Error('Only GLB or self-contained GLTF models are supported.'), { status: 400 })
}
export function validateModelBuffer(buffer, ext = 'glb') {
if (!Buffer.isBuffer(buffer) || buffer.length < 32) {
throw Object.assign(new Error('Model file is too small or invalid.'), { status: 400 })
}
if (ext === 'glb') {
if (buffer.subarray(0, 4).toString('ascii') !== 'glTF') {
throw Object.assign(new Error('GLB files must start with a glTF binary header.'), { status: 400 })
}
return
}
try {
JSON.parse(buffer.toString('utf8'))
} catch {
throw Object.assign(new Error('GLTF files must be valid JSON.'), { status: 400 })
}
}
export function sanitizeModelId(value) {
return sanitizeFileName(String(value)).replace(/\.(?:glb|gltf)$/i, '').replace(/\s+/g, '-').slice(0, 96) || `model-${Date.now()}`
}
export function shouldUseProxy(rawUrl) {
try {
const parsed = new URL(rawUrl)
return !['127.0.0.1', 'localhost', '::1'].includes(parsed.hostname)
} catch {
return true
}
}
export function shouldAttachTripoAuth(rawUrl) {
if (!TRIPO_API_KEY) return false
try {
const host = new URL(rawUrl).hostname
const tripoHost = new URL(TRIPO_API_BASE).hostname
return host === tripoHost || host.endsWith('.tripo3d.ai')
} catch {
return false
}
}
async function fetchRemoteModel(rawUrl) {
const fetchOptions = shouldUseProxy(rawUrl) && OUTBOUND_PROXY_AGENT ? { dispatcher: OUTBOUND_PROXY_AGENT } : {}
const remote = await undiciFetch(rawUrl, fetchOptions)
if (remote.ok && remote.body) return remote
const retry = await fetchWithTripoAuth(rawUrl, fetchOptions)
if (retry.ok && retry.body) return retry
throw Object.assign(new Error(`Model download failed with ${retry.status || remote.status}.`), { status: 502 })
}
async function fetchWithTripoAuth(rawUrl, fetchOptions) {
if (!shouldAttachTripoAuth(rawUrl)) {
return { ok: false, status: 401, body: null }
}
return undiciFetch(rawUrl, {
headers: { Authorization: `Bearer ${TRIPO_API_KEY}` },
...fetchOptions,
})
}
function parseModelBase64(modelBase64) {
const raw = String(modelBase64 || '').replace(/^data:.*?;base64,/, '')
return Buffer.from(raw, 'base64')
}
function isAllowedProxyModelUrl(rawUrl) {
try {
const parsed = new URL(rawUrl)
if (parsed.protocol === 'https:') return true
if (parsed.protocol !== 'http:') return false
return ['127.0.0.1', 'localhost', '::1'].includes(parsed.hostname)
} catch {
return false
}
}
async function streamRemoteModel(remote, response) {
response.writeHead(200, {
'Content-Type': remote.headers.get('content-type') || 'model/gltf-binary',
'Cache-Control': 'private, max-age=3600',
})
await pipeline(Readable.fromWeb(remote.body), response)
}
+53
View File
@@ -0,0 +1,53 @@
export function findFirstValue(value, keys) {
if (!value || typeof value !== 'object') return ''
for (const key of keys) {
if (typeof value[key] === 'string' && value[key]) return value[key]
}
for (const child of Object.values(value)) {
if (Array.isArray(child)) {
for (const item of child) {
const found = findFirstValue(item, keys)
if (found) return found
}
} else if (child && typeof child === 'object') {
const found = findFirstValue(child, keys)
if (found) return found
}
}
return ''
}
export function findModelUrl(value) {
const urls = []
collectUrls(value, urls)
const glb = urls.find((url) => /\.glb(?:[?#]|$)/i.test(url))
if (glb) return glb
return urls.find((url) => /\.gltf(?:[?#]|$)/i.test(url)) || ''
}
export function isSuccessStatus(status) {
return ['success', 'succeeded', 'completed', 'complete', 'done', 'finish', 'finished'].includes(String(status || '').toLowerCase())
}
function collectUrls(value, urls) {
if (!value) return
if (typeof value === 'string') {
if (/^https?:\/\//i.test(value)) urls.push(value)
return
}
if (Array.isArray(value)) {
value.forEach((item) => collectUrls(item, urls))
return
}
if (typeof value === 'object') {
Object.values(value).forEach((item) => collectUrls(item, urls))
}
}
+293
View File
@@ -0,0 +1,293 @@
import { createFalClient } from '@fal-ai/client'
import { fetch as undiciFetch } from 'undici'
import { FAL_API_KEY, FAL_DEFAULT_MODEL, OUTBOUND_PROXY_AGENT } from '../config.mjs'
import { parseDataUrl } from '../http-utils.mjs'
import { cacheRemoteModelAs, hasLocalModel, localModelUrl } from '../model-store.mjs'
import { isSuccessStatus } from '../object-utils.mjs'
export const FAL_MODEL_DEFINITIONS = [
{
id: 'fal-ai/hunyuan3d/v2',
label: 'Hunyuan3D v2',
imageField: 'input_image_url',
defaults: {},
supportsSeed: true,
},
{
id: 'fal-ai/trellis',
label: 'TRELLIS',
imageField: 'image_url',
defaults: { texture_size: '1024' },
supportsSeed: true,
},
{
id: 'fal-ai/triposr',
label: 'TripoSR',
imageField: 'image_url',
defaults: { do_remove_background: true, output_format: 'glb' },
supportsSeed: false,
},
{
id: 'tripo3d/tripo/v2.5/image-to-3d',
label: 'Tripo3D v2.5',
imageField: 'image_url',
defaults: { orientation: 'align_image', pbr: true, texture: 'standard' },
supportsSeed: true,
},
{
id: 'fal-ai/hyper3d/rodin',
label: 'Hyper3D Rodin',
imageField: 'input_image_urls',
defaults: {
geometry_file_format: 'glb',
material: 'PBR',
quality: 'medium',
tier: 'Regular',
},
supportsPrompt: true,
supportsSeed: true,
},
]
export const FAL_MODEL_IDS = new Set(FAL_MODEL_DEFINITIONS.map((model) => model.id))
const FALLBACK_FAL_MODEL = FAL_MODEL_DEFINITIONS[0].id
let falClient = null
export function getFalHealth() {
return {
configured: Boolean(FAL_API_KEY),
defaultModel: normalizeFalModelId(FAL_DEFAULT_MODEL),
models: FAL_MODEL_DEFINITIONS.map(({ id, label }) => ({ id, label })),
}
}
export async function createFalTask(payload) {
const client = getFalClient()
const modelId = normalizeFalModelId(payload.modelId || payload.falModelId || FAL_DEFAULT_MODEL)
const image = parseDataUrl(payload.imageDataUrl)
const blob = new Blob([image.buffer], { type: image.mime })
const imageUrl = await client.storage.upload(blob, { lifecycle: { expiresIn: '1d' } })
const input = buildFalInput(modelId, imageUrl, payload)
const raw = await client.queue.submit(modelId, { input })
const requestId = raw.request_id || raw.requestId
if (!requestId) {
const error = new Error('Fal task response did not include a request id.')
error.detail = raw
throw error
}
return {
provider: 'fal',
taskId: encodeFalTaskId({ modelId, requestId }),
status: normalizeFalStatus(raw.status),
raw,
}
}
export async function getFalTask(taskId) {
const client = getFalClient()
if (!taskId) {
throw Object.assign(new Error('taskId is required.'), { status: 400 })
}
const task = decodeFalTaskId(taskId)
const cacheId = getFalCacheId(task)
if (await hasLocalModel(cacheId, 'glb')) {
return {
provider: 'fal',
taskId,
status: 'success',
progress: 100,
modelUrl: localModelUrl(cacheId, 'glb'),
rawModelUrl: '',
error: '',
raw: { cached: true },
}
}
const statusRaw = await client.queue.status(task.modelId, { requestId: task.requestId, logs: true })
const status = normalizeFalStatus(statusRaw.status)
let modelUrl = ''
let rawModelUrl = ''
let cacheError = ''
let result = null
if (status === 'success') {
result = await client.queue.result(task.modelId, { requestId: task.requestId })
const modelFile = findFalModelFile(result.data ?? result)
rawModelUrl = modelFile.url
if (rawModelUrl) {
try {
modelUrl = await cacheRemoteModelAs(cacheId, rawModelUrl, modelFile.ext)
} catch (error) {
cacheError = error.message || 'Fal model cache failed.'
modelUrl = `/api/3d/model?url=${encodeURIComponent(rawModelUrl)}`
}
} else {
cacheError = 'Fal response did not include a GLB or GLTF URL.'
}
}
return {
provider: 'fal',
taskId,
status,
progress: getFalProgress(statusRaw, status),
modelUrl,
rawModelUrl,
error: statusRaw.error || cacheError || '',
raw: result?.data ?? result ?? statusRaw,
}
}
export function buildFalInput(modelId, imageUrl, payload = {}) {
const model = getFalModelDefinition(modelId)
const input = { ...model.defaults }
if (model.imageField === 'input_image_urls') {
input.input_image_urls = [imageUrl]
} else {
input[model.imageField] = imageUrl
}
if (model.supportsPrompt && payload.prompt) input.prompt = payload.prompt
if (model.supportsSeed && payload.seed !== undefined && Number.isFinite(Number(payload.seed))) {
input.seed = Number(payload.seed)
}
return input
}
export function encodeFalTaskId(task) {
return `fal-${Buffer.from(JSON.stringify(task)).toString('base64url')}`
}
export function decodeFalTaskId(taskId) {
const raw = String(taskId || '')
if (!raw.startsWith('fal-')) {
return { modelId: normalizeFalModelId(FAL_DEFAULT_MODEL), requestId: raw }
}
try {
const parsed = JSON.parse(Buffer.from(raw.slice(4), 'base64url').toString('utf8'))
return {
modelId: normalizeFalModelId(parsed.modelId || FAL_DEFAULT_MODEL),
requestId: parsed.requestId || parsed.request_id || raw,
}
} catch {
return { modelId: normalizeFalModelId(FAL_DEFAULT_MODEL), requestId: raw }
}
}
export function normalizeFalStatus(value) {
const status = String(value || '').toLowerCase()
if (!status) return 'queued'
if (['in_queue', 'queued', 'pending'].includes(status)) return 'queued'
if (['in_progress', 'running', 'processing'].includes(status)) return 'running'
if (['failed', 'error', 'cancelled', 'canceled'].includes(status)) return 'failed'
if (isSuccessStatus(status)) return 'success'
return status
}
export function normalizeFalModelId(value) {
const modelId = String(value || '').trim().replace(/^\/+|\/+$/g, '')
return FAL_MODEL_IDS.has(modelId) ? modelId : FALLBACK_FAL_MODEL
}
export function findFalModelFile(value) {
const candidates = []
collectFalModelFiles(value, candidates, '')
candidates.sort((a, b) => a.score - b.score)
const candidate = candidates[0]
return candidate ? { url: candidate.url, ext: candidate.ext } : { url: '', ext: 'glb' }
}
function getFalClient() {
requireFalKey()
if (falClient) return falClient
falClient = createFalClient({
credentials: FAL_API_KEY,
fetch: (url, options = {}) => undiciFetch(url, {
...options,
...(OUTBOUND_PROXY_AGENT ? { dispatcher: OUTBOUND_PROXY_AGENT } : {}),
}),
})
return falClient
}
function getFalModelDefinition(modelId) {
const normalized = normalizeFalModelId(modelId)
return FAL_MODEL_DEFINITIONS.find((model) => model.id === normalized) || FAL_MODEL_DEFINITIONS[0]
}
function getFalCacheId(task) {
return `fal-${String(task.requestId || '').replace(/^fal-/, '')}`
}
function getFalProgress(raw, status) {
if (status === 'success') return 100
if (status === 'failed') return null
if (status === 'queued') return Number.isFinite(raw.queue_position) ? 0 : 0
if (typeof raw.progress === 'number') return raw.progress
if (typeof raw.percent === 'number') return raw.percent
return null
}
function requireFalKey() {
if (!FAL_API_KEY) {
const error = new Error('FAL_API_KEY is not configured on the backend.')
error.status = 500
throw error
}
}
function collectFalModelFiles(value, candidates, key) {
if (!value) return
if (typeof value === 'string') {
const ext = inferModelExtension({ url: value })
if (ext) candidates.push({ url: value, ext, score: scoreFalModelCandidate(key, ext) })
return
}
if (Array.isArray(value)) {
value.forEach((item) => collectFalModelFiles(item, candidates, key))
return
}
if (typeof value !== 'object') return
const url = value.url || value.file_url || value.download_url || value.uri || value.href
if (typeof url === 'string' && /^https?:\/\//i.test(url)) {
const ext = inferModelExtension({ url, fileName: value.file_name || value.fileName || value.name, contentType: value.content_type || value.contentType || value.mime_type })
if (ext) candidates.push({ url, ext, score: scoreFalModelCandidate(key, ext) })
}
for (const [childKey, child] of Object.entries(value)) {
collectFalModelFiles(child, candidates, childKey)
}
}
function inferModelExtension({ url, fileName, contentType }) {
const source = `${url || ''} ${fileName || ''}`.toLowerCase()
if (/\.glb(?:[?#\s]|$)/i.test(source)) return 'glb'
if (/\.gltf(?:[?#\s]|$)/i.test(source)) return 'gltf'
const type = String(contentType || '').toLowerCase()
if (type.includes('model/gltf-binary') || type.includes('application/octet-stream')) return 'glb'
if (type.includes('model/gltf+json')) return 'gltf'
return ''
}
function scoreFalModelCandidate(key, ext) {
const name = String(key || '').toLowerCase()
const keyScore = name.includes('pbr') ? 0 : name.includes('glb') ? 1 : name.includes('mesh') ? 2 : name.includes('base') ? 3 : 4
const extScore = ext === 'glb' ? 0 : 1
return keyScore * 10 + extScore
}
+146
View File
@@ -0,0 +1,146 @@
import { fetch as undiciFetch } from 'undici'
import { HUNYUAN_API_BASE, HUNYUAN_CREATE_PATH, HUNYUAN_STATUS_PATH } from '../config.mjs'
import { parseDataUrl } from '../http-utils.mjs'
import { hasLocalModel, localModelUrl, saveLocalModel } from '../model-store.mjs'
import { findFirstValue, findModelUrl } from '../object-utils.mjs'
export function getHunyuanHealth() {
return {
configured: Boolean(HUNYUAN_API_BASE),
baseUrl: HUNYUAN_API_BASE,
createPath: HUNYUAN_CREATE_PATH,
statusPath: HUNYUAN_STATUS_PATH,
}
}
export async function createHunyuanTask(payload) {
const image = parseDataUrl(payload.imageDataUrl)
const imageBase64 = image.buffer.toString('base64')
const requestBody = {
image: `data:${image.mime};base64,${imageBase64}`,
image_base64: imageBase64,
prompt: payload.prompt || '',
seed: payload.seed ?? 1234,
remove_background: payload.removeBackground ?? true,
texture: payload.texture ?? true,
pbr: payload.pbr ?? true,
octree_resolution: payload.octreeResolution ?? 256,
num_inference_steps: payload.numInferenceSteps ?? 50,
guidance_scale: payload.guidanceScale ?? 5.5,
face_count: payload.faceCount ?? 60000,
}
const raw = await hunyuanRequest(HUNYUAN_CREATE_PATH, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
})
const data = raw.data || raw
const taskId = findFirstValue(data, ['uid', 'task_id', 'taskId', 'id']) || `hunyuan-${Date.now()}`
const rawModelUrl = findModelUrl(data)
const modelBase64 = findFirstValue(data, ['model_base64', 'modelBase64', 'glb_base64', 'glbBase64'])
let modelUrl = rawModelUrl ? `/api/3d/model?url=${encodeURIComponent(rawModelUrl)}` : ''
if (modelBase64) {
await saveLocalModel(taskId, modelBase64, 'glb')
modelUrl = localModelUrl(taskId, 'glb')
}
return {
provider: 'hunyuan',
taskId,
status: modelUrl ? 'success' : 'queued',
modelUrl,
raw: sanitizeHunyuanRaw(raw),
}
}
export async function getHunyuanTask(taskId) {
if (!taskId) {
throw Object.assign(new Error('taskId is required.'), { status: 400 })
}
if (await hasLocalModel(taskId, 'glb')) {
return {
provider: 'hunyuan',
taskId,
status: 'success',
progress: 100,
modelUrl: localModelUrl(taskId, 'glb'),
rawModelUrl: '',
error: '',
raw: {},
}
}
const raw = await hunyuanRequest(`${HUNYUAN_STATUS_PATH}/${encodeURIComponent(taskId)}`, { method: 'GET' })
const data = raw.data || raw
const status = normalizeHunyuanStatus(data.status || data.task_status || data.state || data.message || 'running')
const progress = data.progress ?? data.percent ?? null
const rawModelUrl = findModelUrl(data)
const modelBase64 = findFirstValue(data, ['model_base64', 'modelBase64', 'glb_base64', 'glbBase64'])
let modelUrl = rawModelUrl ? `/api/3d/model?url=${encodeURIComponent(rawModelUrl)}` : ''
if (modelBase64) {
await saveLocalModel(taskId, modelBase64, 'glb')
modelUrl = localModelUrl(taskId, 'glb')
}
return {
provider: 'hunyuan',
taskId,
status,
progress,
modelUrl,
rawModelUrl,
error: data.error || data.message || '',
raw: sanitizeHunyuanRaw(raw),
}
}
async function hunyuanRequest(requestPath, options = {}) {
let response
try {
response = await undiciFetch(`${HUNYUAN_API_BASE.replace(/\/$/, '')}${requestPath.startsWith('/') ? requestPath : `/${requestPath}`}`, options)
} catch (error) {
const wrapped = new Error(`Hunyuan3D local server unavailable at ${HUNYUAN_API_BASE}. Start the local Hunyuan3D API server or switch provider.`)
wrapped.detail = {
path: requestPath,
cause: error.cause?.message || error.cause?.code || error.message,
}
throw wrapped
}
const text = await response.text()
let data
try {
data = text ? JSON.parse(text) : {}
} catch {
data = { message: text || 'Non-JSON response from Hunyuan3D.' }
}
if (!response.ok || (typeof data.code === 'number' && data.code !== 0)) {
const error = new Error(data.message || data.error || `Hunyuan3D request failed with ${response.status}.`)
error.status = response.status || 502
error.detail = sanitizeHunyuanRaw(data)
throw error
}
return data
}
function normalizeHunyuanStatus(status) {
const value = String(status || '').toLowerCase()
if (['success', 'succeeded', 'completed', 'complete', 'done', 'finish', 'finished'].includes(value)) return 'success'
if (['failed', 'error', 'cancelled', 'canceled'].includes(value)) return 'failed'
if (['queued', 'pending', 'waiting'].includes(value)) return 'queued'
return 'running'
}
function sanitizeHunyuanRaw(raw) {
if (!raw || typeof raw !== 'object') return raw
return JSON.parse(JSON.stringify(raw, (key, value) => {
if (['model_base64', 'modelBase64', 'glb_base64', 'glbBase64'].includes(key)) return '[base64 omitted]'
return value
}))
}
+246
View File
@@ -0,0 +1,246 @@
import { Blob } from 'node:buffer'
import { fetch as undiciFetch, FormData } from 'undici'
import {
OUTBOUND_PROXY_AGENT,
RODIN_API_BASE,
RODIN_API_KEY,
RODIN_MATERIAL,
RODIN_MESH_MODE,
RODIN_QUALITY,
RODIN_TIER,
hasOutboundProxy,
} from '../config.mjs'
import { parseDataUrl, sanitizeFileName } from '../http-utils.mjs'
import { cacheRemoteModelAs, hasLocalModel, localModelUrl } from '../model-store.mjs'
import { findFirstValue } from '../object-utils.mjs'
export function getRodinHealth() {
return {
configured: Boolean(RODIN_API_KEY),
baseUrl: RODIN_API_BASE,
tier: RODIN_TIER,
quality: RODIN_QUALITY,
meshMode: RODIN_MESH_MODE,
material: RODIN_MATERIAL,
}
}
export async function createRodinTask(payload) {
requireRodinKey()
const image = parseDataUrl(payload.imageDataUrl)
const fileName = sanitizeFileName(payload.fileName || `cell-reference.${image.ext}`)
const form = new FormData()
form.append('images', new Blob([image.buffer], { type: image.mime }), fileName)
form.append('geometry_file_format', 'glb')
form.append('material', payload.material || RODIN_MATERIAL)
form.append('quality', payload.quality || RODIN_QUALITY)
form.append('tier', payload.tier || RODIN_TIER)
form.append('mesh_mode', payload.meshMode || RODIN_MESH_MODE)
if (payload.prompt) form.append('prompt', payload.prompt)
if (payload.seed !== undefined) form.append('seed', String(payload.seed))
const raw = await rodinRequest('/rodin', {
method: 'POST',
body: form,
})
const taskUuid = findFirstValue(raw, ['uuid', 'task_uuid', 'taskUuid', 'taskId', 'id'])
const subscriptionKey = findFirstValue(raw.jobs || raw, ['subscription_key', 'subscriptionKey'])
if (!taskUuid) {
const error = new Error('Rodin task response did not include a task uuid.')
error.detail = sanitizeRodinRaw(raw)
throw error
}
if (!subscriptionKey) {
const error = new Error('Rodin task response did not include a subscription key.')
error.detail = sanitizeRodinRaw(raw)
throw error
}
return {
provider: 'rodin',
taskId: encodeRodinTaskId({ taskUuid, subscriptionKey }),
status: 'queued',
raw: sanitizeRodinRaw(raw),
}
}
export async function getRodinTask(taskId) {
requireRodinKey()
if (!taskId) {
throw Object.assign(new Error('taskId is required.'), { status: 400 })
}
const rodinTask = decodeRodinTaskId(taskId)
if (await hasLocalModel(rodinTask.taskUuid, 'glb')) {
return {
provider: 'rodin',
taskId,
status: 'success',
progress: 100,
modelUrl: localModelUrl(rodinTask.taskUuid, 'glb'),
rawModelUrl: '',
error: '',
raw: { cached: true },
}
}
const raw = await rodinRequest('/status', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subscription_key: rodinTask.subscriptionKey }),
})
const jobs = Array.isArray(raw.jobs) ? raw.jobs : []
const status = normalizeRodinStatus(jobs.map((job) => job.status).filter(Boolean))
let modelUrl = ''
let rawModelUrl = ''
let cacheError = ''
if (status === 'success') {
try {
const download = await getRodinDownload(rodinTask.taskUuid)
rawModelUrl = download.url
modelUrl = await cacheRemoteModelAs(rodinTask.taskUuid, rawModelUrl, download.ext)
} catch (error) {
cacheError = error.message || 'Rodin model download failed.'
}
}
return {
provider: 'rodin',
taskId,
status,
progress: getRodinProgress(status, jobs),
modelUrl,
rawModelUrl,
error: raw.error || cacheError || '',
raw: sanitizeRodinRaw(raw),
}
}
export function encodeRodinTaskId(task) {
return `rodin-${Buffer.from(JSON.stringify(task)).toString('base64url')}`
}
export function decodeRodinTaskId(taskId) {
const raw = String(taskId || '')
if (!raw.startsWith('rodin-')) {
return { taskUuid: raw, subscriptionKey: raw }
}
try {
const parsed = JSON.parse(Buffer.from(raw.slice(6), 'base64url').toString('utf8'))
return {
taskUuid: parsed.taskUuid || parsed.uuid || raw,
subscriptionKey: parsed.subscriptionKey || parsed.subscription_key || parsed.taskUuid || raw,
}
} catch {
return { taskUuid: raw, subscriptionKey: raw }
}
}
export function normalizeRodinStatus(statuses) {
const values = (Array.isArray(statuses) ? statuses : [statuses]).map((status) => String(status || '').trim().toLowerCase())
if (!values.length) return 'running'
if (values.some((status) => ['failed', 'failure', 'error', 'cancelled', 'canceled'].includes(status))) return 'failed'
if (values.every((status) => ['done', 'success', 'succeeded', 'completed', 'complete', 'finish', 'finished'].includes(status))) return 'success'
if (values.some((status) => ['waiting', 'queued', 'pending'].includes(status))) return 'queued'
return 'running'
}
export function findRodinDownloadItem(raw) {
const items = Array.isArray(raw?.list) ? raw.list : []
return items.find((entry) => /\.glb(?:[?#]|$)/i.test(entry.name || entry.url || ''))
|| items.find((entry) => /\.gltf(?:[?#]|$)/i.test(entry.name || entry.url || ''))
|| items.find((entry) => /^https?:\/\//i.test(entry.url || ''))
|| null
}
function requireRodinKey() {
if (!RODIN_API_KEY) {
const error = new Error('RODIN_API_KEY is not configured on the backend.')
error.status = 500
throw error
}
}
async function getRodinDownload(taskUuid) {
const raw = await rodinRequest('/download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ task_uuid: taskUuid }),
})
const item = findRodinDownloadItem(raw)
if (!item?.url) {
const error = new Error('Rodin download response did not include a model URL.')
error.detail = sanitizeRodinRaw(raw)
throw error
}
const ext = /\.gltf(?:[?#]|$)/i.test(item.name || item.url) ? 'gltf' : 'glb'
return { url: item.url, ext, raw }
}
function getRodinProgress(status, jobs) {
if (status === 'success') return 100
if (status === 'queued') return 0
if (!Array.isArray(jobs) || !jobs.length) return null
const done = jobs.filter((job) => normalizeRodinStatus(job.status) === 'success').length
if (!done) return null
return Math.round((done / jobs.length) * 100)
}
async function rodinRequest(requestPath, options = {}) {
let response
try {
response = await undiciFetch(`${RODIN_API_BASE.replace(/\/$/, '')}${requestPath.startsWith('/') ? requestPath : `/${requestPath}`}`, {
...options,
...(OUTBOUND_PROXY_AGENT ? { dispatcher: OUTBOUND_PROXY_AGENT } : {}),
headers: {
Authorization: `Bearer ${RODIN_API_KEY}`,
Accept: 'application/json',
...(options.headers || {}),
},
})
} catch (error) {
const wrapped = new Error(`Rodin 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 Rodin.' }
}
if (!response.ok || data.error) {
const error = new Error(data.message || data.error || `Rodin request failed with ${response.status}.`)
error.status = response.status || 502
error.detail = sanitizeRodinRaw(data)
throw error
}
return data
}
function sanitizeRodinRaw(raw) {
if (!raw || typeof raw !== 'object') return raw
return JSON.parse(JSON.stringify(raw, (key, value) => {
if (['subscription_key', 'subscriptionKey'].includes(key)) return '[secret omitted]'
return value
}))
}
+277
View File
@@ -0,0 +1,277 @@
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()
}
+225
View File
@@ -0,0 +1,225 @@
import { fetch as undiciFetch } from 'undici'
import {
OPENAI_API_BASE,
OPENAI_API_KEY,
OPENAI_VISION_MODEL,
OUTBOUND_PROXY_AGENT,
VISION_PROVIDER,
hasOutboundProxy,
} from '../config.mjs'
import { parseDataUrl, sanitizeFileName } from '../http-utils.mjs'
const CATEGORY_IDS = new Set(['artifact', 'road', 'vessel', 'aircraft', 'product', 'specimen'])
const CATEGORY_LABELS = {
artifact: 'Museum Artifact',
road: 'Performance Vehicle',
vessel: 'Naval Vessel',
aircraft: 'Aircraft',
product: 'Product Object',
specimen: 'Organic Specimen',
}
export function getVisionHealth() {
return {
provider: VISION_PROVIDER,
configured: VISION_PROVIDER === 'openai' && Boolean(OPENAI_API_KEY),
model: VISION_PROVIDER === 'openai' ? OPENAI_VISION_MODEL : '',
baseUrl: VISION_PROVIDER === 'openai' ? OPENAI_API_BASE : '',
}
}
export async function analyzeAssetImage(payload = {}) {
const image = parseDataUrl(payload.imageDataUrl)
const fileName = sanitizeFileName(payload.fileName || `asset-reference.${image.ext}`)
if (VISION_PROVIDER !== 'openai') {
return unavailableInsight(fileName, `VISION_PROVIDER=${VISION_PROVIDER} is not supported yet.`)
}
if (!OPENAI_API_KEY) {
return unavailableInsight(fileName, 'OPENAI_API_KEY is not configured on the backend.')
}
const raw = await openAiVisionRequest(payload.imageDataUrl, fileName)
const content = raw?.choices?.[0]?.message?.content || ''
const parsed = extractJsonObject(content)
return normalizeVisionInsight(parsed, {
fileName,
provider: 'openai',
model: OPENAI_VISION_MODEL,
raw,
})
}
export function normalizeVisionInsight(raw = {}, context = {}) {
const categoryId = normalizeCategoryId(raw.categoryId || raw.category || raw.type)
const objectName = cleanText(raw.objectName || raw.name || raw.title || context.fileName || 'Uploaded asset', 90)
const tags = normalizeTags(raw.tags)
return {
provider: context.provider || 'openai',
model: context.model || '',
configured: true,
status: 'success',
objectName,
categoryId,
categoryLabel: cleanText(raw.categoryLabel || CATEGORY_LABELS[categoryId], 48),
description: cleanText(raw.description || raw.summary, 420),
material: cleanText(raw.material || raw.materials, 220),
inspectionFocus: cleanText(raw.inspectionFocus || raw.structureFocus || raw.focus, 220),
presentation: cleanText(raw.presentation || raw.demo || raw.scene, 320),
generationPrompt: cleanText(raw.generationPrompt || raw.prompt, 520),
tags,
confidence: normalizeConfidence(raw.confidence),
reason: cleanText(raw.reason || raw.rationale, 260),
analyzedAt: new Date().toISOString(),
}
}
export function extractJsonObject(content) {
if (!content || typeof content !== 'string') {
throw new Error('Vision model did not return text content.')
}
try {
return JSON.parse(content)
} catch {
const match = content.match(/\{[\s\S]*\}/)
if (!match) throw new Error('Vision model did not return a JSON object.')
return JSON.parse(match[0])
}
}
async function openAiVisionRequest(imageDataUrl, fileName) {
let response
try {
response = await undiciFetch(`${OPENAI_API_BASE.replace(/\/$/, '')}/chat/completions`, {
method: 'POST',
...(OUTBOUND_PROXY_AGENT ? { dispatcher: OUTBOUND_PROXY_AGENT } : {}),
headers: {
Authorization: `Bearer ${OPENAI_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: OPENAI_VISION_MODEL,
response_format: { type: 'json_object' },
temperature: 0.2,
messages: [
{
role: 'system',
content: [
'You analyze a reference image for a 3D model studio.',
'Return only a compact JSON object.',
'Allowed categoryId values: artifact, road, vessel, aircraft, product, specimen.',
'Choose vessel for aircraft carriers, warships, ships, or submarines, even if the word aircraft appears.',
'Choose artifact for museum relics, bronze objects, masks, statues, ancient objects, or archaeological items.',
'Describe what matters for making and presenting the 3D asset, not generic biology unless it is truly biological.',
].join(' '),
},
{
role: 'user',
content: [
{
type: 'text',
text: [
`File name: ${fileName}`,
'Return JSON with these keys:',
'objectName, categoryId, categoryLabel, description, material, inspectionFocus, presentation, generationPrompt, tags, confidence, reason.',
'Keep objectName short and human-readable.',
'generationPrompt should help an image-to-3D model preserve one integrated object, correct silhouette, materials, and key structure.',
].join(' '),
},
{
type: 'image_url',
image_url: { url: imageDataUrl },
},
],
},
],
}),
})
} catch (error) {
const wrapped = new Error(`OpenAI vision network request failed: ${error.message}`)
wrapped.detail = {
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 = { error: { message: text || 'Non-JSON response from OpenAI.' } }
}
if (!response.ok || data.error) {
const error = new Error(data.error?.message || data.message || `OpenAI vision request failed with ${response.status}.`)
error.status = response.status || 502
error.detail = sanitizeOpenAiRaw(data)
throw error
}
return sanitizeOpenAiRaw(data)
}
function unavailableInsight(fileName, message) {
return {
provider: VISION_PROVIDER,
model: VISION_PROVIDER === 'openai' ? OPENAI_VISION_MODEL : '',
configured: false,
status: 'unavailable',
objectName: fileName.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim() || 'Uploaded asset',
categoryId: '',
categoryLabel: '',
description: '',
material: '',
inspectionFocus: '',
presentation: '',
generationPrompt: '',
tags: [],
confidence: 0,
reason: message,
analyzedAt: new Date().toISOString(),
}
}
function normalizeCategoryId(value) {
const normalized = String(value || '').trim().toLowerCase().replace(/\s+/g, '-')
if (CATEGORY_IDS.has(normalized)) return normalized
if (['car', 'vehicle', 'automobile', 'supercar', 'truck'].includes(normalized)) return 'road'
if (['ship', 'carrier', 'warship', 'naval', 'submarine'].includes(normalized)) return 'vessel'
if (['plane', 'airplane', 'fighter', 'fighter-jet', 'jet'].includes(normalized)) return 'aircraft'
if (['relic', 'museum', 'bronze', 'mask', 'statue'].includes(normalized)) return 'artifact'
if (['cell', 'biology', 'organic', 'organism'].includes(normalized)) return 'specimen'
return 'product'
}
function normalizeTags(tags) {
const rawTags = Array.isArray(tags) ? tags : String(tags || '').split(/[,\n]/)
return [...new Set(rawTags.map((tag) => cleanText(tag, 28).toLowerCase()).filter(Boolean))].slice(0, 8)
}
function normalizeConfidence(value) {
const confidence = Number(value)
if (!Number.isFinite(confidence)) return 0
return Math.max(0, Math.min(1, confidence > 1 ? confidence / 100 : confidence))
}
function cleanText(value, maxLength) {
const text = String(value || '').replace(/\s+/g, ' ').trim()
if (!text) return ''
return text.length > maxLength ? `${text.slice(0, maxLength - 1).trim()}` : text
}
function sanitizeOpenAiRaw(raw) {
if (!raw || typeof raw !== 'object') return raw
return JSON.parse(JSON.stringify(raw, (key, value) => {
if (['authorization', 'api_key', 'apiKey'].includes(String(key).toLowerCase())) return '[secret omitted]'
return value
}))
}