chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)]),
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}))
|
||||
}
|
||||
@@ -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
|
||||
}))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user