refactor(ai-render-workbench): split ai render backend flow
This commit is contained in:
parent
135cab0990
commit
d02f27b28f
@ -0,0 +1,464 @@
|
||||
import sharp from 'sharp'
|
||||
|
||||
const CLEAN_BACKGROUND_WORKBENCH_MODES = new Set(['white-background', 'eight-view'])
|
||||
|
||||
export interface WorkbenchRenderImageBuffer {
|
||||
buffer: Buffer
|
||||
extension: string
|
||||
contentType: string
|
||||
}
|
||||
|
||||
function computePixelLuma(red: number, green: number, blue: number): number {
|
||||
return 0.2126 * red + 0.7152 * green + 0.0722 * blue
|
||||
}
|
||||
|
||||
function computePixelChroma(red: number, green: number, blue: number): number {
|
||||
return Math.max(red, green, blue) - Math.min(red, green, blue)
|
||||
}
|
||||
|
||||
function isStrongForegroundPixel(red: number, green: number, blue: number): boolean {
|
||||
return computePixelLuma(red, green, blue) <= 170 || computePixelChroma(red, green, blue) >= 18
|
||||
}
|
||||
|
||||
function componentIntersectsCentralViewport(params: {
|
||||
left: number
|
||||
right: number
|
||||
top: number
|
||||
bottom: number
|
||||
width: number
|
||||
height: number
|
||||
}): boolean {
|
||||
const centralLeft = Math.floor(params.width * 0.2)
|
||||
const centralRight = Math.ceil(params.width * 0.8)
|
||||
const centralTop = Math.floor(params.height * 0.18)
|
||||
const centralBottom = Math.ceil(params.height * 0.82)
|
||||
|
||||
return (
|
||||
params.right >= centralLeft &&
|
||||
params.left <= centralRight &&
|
||||
params.bottom >= centralTop &&
|
||||
params.top <= centralBottom
|
||||
)
|
||||
}
|
||||
|
||||
function collectMainForegroundProfile(params: {
|
||||
data: Buffer
|
||||
width: number
|
||||
height: number
|
||||
channels: number
|
||||
}): {
|
||||
left: number
|
||||
right: number
|
||||
top: number
|
||||
bottom: number
|
||||
rowMin: Int32Array
|
||||
rowMax: Int32Array
|
||||
} | null {
|
||||
const pixelCount = params.width * params.height
|
||||
const visited = new Uint8Array(pixelCount)
|
||||
const queue = new Uint32Array(pixelCount)
|
||||
const minComponentArea = Math.max(256, Math.floor(pixelCount * 0.0004))
|
||||
|
||||
let bestComponent:
|
||||
| {
|
||||
area: number
|
||||
seedIndex: number
|
||||
left: number
|
||||
right: number
|
||||
top: number
|
||||
bottom: number
|
||||
}
|
||||
| null = null
|
||||
|
||||
for (let pixelIndex = 0; pixelIndex < pixelCount; pixelIndex += 1) {
|
||||
if (visited[pixelIndex] === 1) continue
|
||||
const offset = pixelIndex * params.channels
|
||||
if (
|
||||
!isStrongForegroundPixel(
|
||||
params.data[offset] ?? 0,
|
||||
params.data[offset + 1] ?? 0,
|
||||
params.data[offset + 2] ?? 0,
|
||||
)
|
||||
) {
|
||||
visited[pixelIndex] = 1
|
||||
continue
|
||||
}
|
||||
|
||||
let head = 0
|
||||
let tail = 0
|
||||
let area = 0
|
||||
let left = params.width
|
||||
let right = -1
|
||||
let top = params.height
|
||||
let bottom = -1
|
||||
let touchesBorder = false
|
||||
|
||||
queue[tail] = pixelIndex
|
||||
tail += 1
|
||||
visited[pixelIndex] = 1
|
||||
|
||||
while (head < tail) {
|
||||
const currentIndex = queue[head]!
|
||||
head += 1
|
||||
area += 1
|
||||
const x = currentIndex % params.width
|
||||
const y = Math.floor(currentIndex / params.width)
|
||||
if (x === 0 || y === 0 || x === params.width - 1 || y === params.height - 1) {
|
||||
touchesBorder = true
|
||||
}
|
||||
if (x < left) left = x
|
||||
if (x > right) right = x
|
||||
if (y < top) top = y
|
||||
if (y > bottom) bottom = y
|
||||
|
||||
const neighborCandidates = [
|
||||
[x - 1, y],
|
||||
[x + 1, y],
|
||||
[x, y - 1],
|
||||
[x, y + 1],
|
||||
] as const
|
||||
neighborCandidates.forEach(([nextX, nextY]) => {
|
||||
if (nextX < 0 || nextY < 0 || nextX >= params.width || nextY >= params.height) return
|
||||
const nextIndex = nextY * params.width + nextX
|
||||
if (visited[nextIndex] === 1) return
|
||||
const nextOffset = nextIndex * params.channels
|
||||
if (
|
||||
!isStrongForegroundPixel(
|
||||
params.data[nextOffset] ?? 0,
|
||||
params.data[nextOffset + 1] ?? 0,
|
||||
params.data[nextOffset + 2] ?? 0,
|
||||
)
|
||||
) {
|
||||
visited[nextIndex] = 1
|
||||
return
|
||||
}
|
||||
|
||||
visited[nextIndex] = 1
|
||||
queue[tail] = nextIndex
|
||||
tail += 1
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
touchesBorder ||
|
||||
area < minComponentArea ||
|
||||
!componentIntersectsCentralViewport({
|
||||
left,
|
||||
right,
|
||||
top,
|
||||
bottom,
|
||||
width: params.width,
|
||||
height: params.height,
|
||||
})
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!bestComponent || area > bestComponent.area) {
|
||||
bestComponent = {
|
||||
area,
|
||||
seedIndex: pixelIndex,
|
||||
left,
|
||||
right,
|
||||
top,
|
||||
bottom,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!bestComponent) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rowMin = new Int32Array(params.height)
|
||||
rowMin.fill(params.width)
|
||||
const rowMax = new Int32Array(params.height)
|
||||
rowMax.fill(-1)
|
||||
const componentVisited = new Uint8Array(pixelCount)
|
||||
let head = 0
|
||||
let tail = 0
|
||||
queue[tail] = bestComponent.seedIndex
|
||||
tail += 1
|
||||
componentVisited[bestComponent.seedIndex] = 1
|
||||
|
||||
while (head < tail) {
|
||||
const currentIndex = queue[head]!
|
||||
head += 1
|
||||
const x = currentIndex % params.width
|
||||
const y = Math.floor(currentIndex / params.width)
|
||||
if (x < rowMin[y]) rowMin[y] = x
|
||||
if (x > rowMax[y]) rowMax[y] = x
|
||||
|
||||
const neighborCandidates = [
|
||||
[x - 1, y],
|
||||
[x + 1, y],
|
||||
[x, y - 1],
|
||||
[x, y + 1],
|
||||
] as const
|
||||
neighborCandidates.forEach(([nextX, nextY]) => {
|
||||
if (nextX < 0 || nextY < 0 || nextX >= params.width || nextY >= params.height) return
|
||||
const nextIndex = nextY * params.width + nextX
|
||||
if (componentVisited[nextIndex] === 1) return
|
||||
const nextOffset = nextIndex * params.channels
|
||||
if (
|
||||
!isStrongForegroundPixel(
|
||||
params.data[nextOffset] ?? 0,
|
||||
params.data[nextOffset + 1] ?? 0,
|
||||
params.data[nextOffset + 2] ?? 0,
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
componentVisited[nextIndex] = 1
|
||||
queue[tail] = nextIndex
|
||||
tail += 1
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
left: bestComponent.left,
|
||||
right: bestComponent.right,
|
||||
top: bestComponent.top,
|
||||
bottom: bestComponent.bottom,
|
||||
rowMin,
|
||||
rowMax,
|
||||
}
|
||||
}
|
||||
|
||||
function isBackgroundCandidatePixel(params: {
|
||||
red: number
|
||||
green: number
|
||||
blue: number
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}): boolean {
|
||||
const luma = computePixelLuma(params.red, params.green, params.blue)
|
||||
const chroma = computePixelChroma(params.red, params.green, params.blue)
|
||||
const edgeBand = Math.max(12, Math.floor(Math.min(params.width, params.height) * 0.16))
|
||||
const edgeDistance = Math.min(
|
||||
params.x,
|
||||
params.width - 1 - params.x,
|
||||
params.y,
|
||||
params.height - 1 - params.y,
|
||||
)
|
||||
const relaxedEdgeThreshold = edgeDistance <= edgeBand
|
||||
|
||||
return luma >= (relaxedEdgeThreshold ? 150 : 172) && chroma <= (relaxedEdgeThreshold ? 72 : 56)
|
||||
}
|
||||
|
||||
function computeBackgroundWhiteningStrength(params: {
|
||||
red: number
|
||||
green: number
|
||||
blue: number
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}): number {
|
||||
const luma = computePixelLuma(params.red, params.green, params.blue)
|
||||
const chroma = computePixelChroma(params.red, params.green, params.blue)
|
||||
const edgeBand = Math.max(12, Math.floor(Math.min(params.width, params.height) * 0.16))
|
||||
const edgeDistance = Math.min(
|
||||
params.x,
|
||||
params.width - 1 - params.x,
|
||||
params.y,
|
||||
params.height - 1 - params.y,
|
||||
)
|
||||
const edgeBoost =
|
||||
edgeDistance >= edgeBand || edgeBand <= 0 ? 0 : (1 - edgeDistance / edgeBand) * 0.12
|
||||
const lumaBoost = Math.max(0, luma - 148) / 107
|
||||
const chromaBoost = Math.max(0, 56 - chroma) / 56
|
||||
|
||||
return Math.min(1, Math.max(0.6, 0.55 + lumaBoost * 0.35 + chromaBoost * 0.1 + edgeBoost))
|
||||
}
|
||||
|
||||
export function shouldCleanWorkbenchResultBackground(mode: string | null | undefined): boolean {
|
||||
return typeof mode === 'string' && CLEAN_BACKGROUND_WORKBENCH_MODES.has(mode)
|
||||
}
|
||||
|
||||
export async function cleanWorkbenchResultBackground(
|
||||
params: WorkbenchRenderImageBuffer,
|
||||
): Promise<WorkbenchRenderImageBuffer> {
|
||||
const normalized = sharp(params.buffer)
|
||||
.rotate()
|
||||
.flatten({ background: '#ffffff' })
|
||||
.toColorspace('srgb')
|
||||
const { data, info } = await normalized.raw().toBuffer({ resolveWithObject: true })
|
||||
const width = info.width
|
||||
const height = info.height
|
||||
const channels = info.channels
|
||||
|
||||
if (!width || !height || channels < 3) {
|
||||
return params
|
||||
}
|
||||
|
||||
const pixelCount = width * height
|
||||
const output = Buffer.from(data)
|
||||
let changed = false
|
||||
const foregroundProfile = collectMainForegroundProfile({
|
||||
data: output,
|
||||
width,
|
||||
height,
|
||||
channels,
|
||||
})
|
||||
|
||||
if (foregroundProfile) {
|
||||
const xMargin = Math.min(96, Math.max(28, Math.floor(width * 0.03)))
|
||||
const bottomMargin = Math.min(160, Math.max(64, Math.floor(height * 0.08)))
|
||||
const fallbackLeft = Math.max(0, foregroundProfile.left - xMargin)
|
||||
const fallbackRight = Math.min(width - 1, foregroundProfile.right + xMargin)
|
||||
const bottomEdgeLeft =
|
||||
foregroundProfile.rowMax[foregroundProfile.bottom]! >= 0
|
||||
? Math.max(0, foregroundProfile.rowMin[foregroundProfile.bottom]! - xMargin)
|
||||
: fallbackLeft
|
||||
const bottomEdgeRight =
|
||||
foregroundProfile.rowMax[foregroundProfile.bottom]! >= 0
|
||||
? Math.min(width - 1, foregroundProfile.rowMax[foregroundProfile.bottom]! + xMargin)
|
||||
: fallbackRight
|
||||
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
let allowedLeft = fallbackLeft
|
||||
let allowedRight = fallbackRight
|
||||
|
||||
if (foregroundProfile.rowMax[y]! >= 0) {
|
||||
allowedLeft = Math.max(0, foregroundProfile.rowMin[y]! - xMargin)
|
||||
allowedRight = Math.min(width - 1, foregroundProfile.rowMax[y]! + xMargin)
|
||||
} else if (y < foregroundProfile.top || y > foregroundProfile.bottom + bottomMargin) {
|
||||
allowedLeft = width
|
||||
allowedRight = -1
|
||||
} else if (y > foregroundProfile.bottom) {
|
||||
allowedLeft = bottomEdgeLeft
|
||||
allowedRight = bottomEdgeRight
|
||||
}
|
||||
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
if (x >= allowedLeft && x <= allowedRight) continue
|
||||
const offset = (y * width + x) * channels
|
||||
changed = true
|
||||
output[offset] = 255
|
||||
output[offset + 1] = 255
|
||||
output[offset + 2] = 255
|
||||
if (channels > 3) {
|
||||
output[offset + 3] = 255
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const marked = new Uint8Array(pixelCount)
|
||||
const queue = new Uint32Array(pixelCount)
|
||||
let head = 0
|
||||
let tail = 0
|
||||
|
||||
const tryMarkPixel = (x: number, y: number) => {
|
||||
if (x < 0 || y < 0 || x >= width || y >= height) return
|
||||
const pixelIndex = y * width + x
|
||||
if (marked[pixelIndex] === 1) return
|
||||
|
||||
const offset = pixelIndex * channels
|
||||
if (
|
||||
!isBackgroundCandidatePixel({
|
||||
red: output[offset] ?? 0,
|
||||
green: output[offset + 1] ?? 0,
|
||||
blue: output[offset + 2] ?? 0,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
marked[pixelIndex] = 1
|
||||
queue[tail] = pixelIndex
|
||||
tail += 1
|
||||
}
|
||||
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
tryMarkPixel(x, 0)
|
||||
tryMarkPixel(x, height - 1)
|
||||
}
|
||||
for (let y = 1; y < height - 1; y += 1) {
|
||||
tryMarkPixel(0, y)
|
||||
tryMarkPixel(width - 1, y)
|
||||
}
|
||||
|
||||
while (head < tail) {
|
||||
const pixelIndex = queue[head]!
|
||||
head += 1
|
||||
const x = pixelIndex % width
|
||||
const y = Math.floor(pixelIndex / width)
|
||||
tryMarkPixel(x - 1, y)
|
||||
tryMarkPixel(x + 1, y)
|
||||
tryMarkPixel(x, y - 1)
|
||||
tryMarkPixel(x, y + 1)
|
||||
}
|
||||
|
||||
if (tail === 0 && !changed) {
|
||||
return params
|
||||
}
|
||||
|
||||
for (let pixelIndex = 0; pixelIndex < pixelCount; pixelIndex += 1) {
|
||||
if (marked[pixelIndex] !== 1) continue
|
||||
const offset = pixelIndex * channels
|
||||
const x = pixelIndex % width
|
||||
const y = Math.floor(pixelIndex / width)
|
||||
const red = output[offset] ?? 0
|
||||
const green = output[offset + 1] ?? 0
|
||||
const blue = output[offset + 2] ?? 0
|
||||
const strength = computeBackgroundWhiteningStrength({
|
||||
red,
|
||||
green,
|
||||
blue,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
|
||||
changed = true
|
||||
output[offset] = Math.round(red + (255 - red) * strength)
|
||||
output[offset + 1] = Math.round(green + (255 - green) * strength)
|
||||
output[offset + 2] = Math.round(blue + (255 - blue) * strength)
|
||||
if (channels > 3) {
|
||||
output[offset + 3] = 255
|
||||
}
|
||||
}
|
||||
|
||||
let encoded = sharp(output, {
|
||||
raw: {
|
||||
width,
|
||||
height,
|
||||
channels,
|
||||
},
|
||||
})
|
||||
|
||||
if (params.extension === 'png') {
|
||||
encoded = encoded.png()
|
||||
return {
|
||||
buffer: await encoded.toBuffer(),
|
||||
extension: 'png',
|
||||
contentType: 'image/png',
|
||||
}
|
||||
}
|
||||
|
||||
if (params.extension === 'webp') {
|
||||
encoded = encoded.webp({ quality: 92 })
|
||||
return {
|
||||
buffer: await encoded.toBuffer(),
|
||||
extension: 'webp',
|
||||
contentType: 'image/webp',
|
||||
}
|
||||
}
|
||||
|
||||
encoded = encoded.jpeg({ quality: 92, mozjpeg: true })
|
||||
return {
|
||||
buffer: await encoded.toBuffer(),
|
||||
extension: 'jpg',
|
||||
contentType: 'image/jpeg',
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,503 @@
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import { AssistedAssemblyAiRenderStatus } from '@prisma/client'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import {
|
||||
createObjectReadUrl,
|
||||
uploadBufferToObjectStorageWithMetadata,
|
||||
} from '@/lib/storage'
|
||||
import type { AuthUser } from '@/server/core/types'
|
||||
import { aiRenderWorkbenchService } from './ai-render-workbench.service'
|
||||
import {
|
||||
cleanWorkbenchResultBackground,
|
||||
shouldCleanWorkbenchResultBackground,
|
||||
} from './assisted-assembly-ai-render.background'
|
||||
import {
|
||||
buildEnhancedMaterialMappingPromptSuffix,
|
||||
extractEnhancedMaterialEntriesFromSnapshot,
|
||||
type MaterialTextureEntry,
|
||||
} from './assisted-assembly-ai-render.materials'
|
||||
import {
|
||||
normalizeResultImages,
|
||||
toAiRenderDTO,
|
||||
type AssistedAssemblyAiRenderDTO,
|
||||
type AssistedAssemblyAiRenderImageDTO,
|
||||
} from './assisted-assembly-ai-render.records'
|
||||
import {
|
||||
fetchImageBuffer,
|
||||
getTemporaryRenderExpiresAt,
|
||||
normalizeWorkbenchResultOutputSize,
|
||||
uploadTemporaryWorkbenchReferenceImage,
|
||||
uploadTemporaryWorkbenchResultImage,
|
||||
} from './assisted-assembly-ai-render.temp-storage'
|
||||
import {
|
||||
decodeReferenceImageDataUrl,
|
||||
decodeReferenceImageDataUrls,
|
||||
ensureJsonObject,
|
||||
normalizeImageCount,
|
||||
normalizeMaterialPartAnnotations,
|
||||
normalizeNegativePrompt,
|
||||
normalizeOptionalText,
|
||||
normalizeOptionalViewIndex,
|
||||
normalizePrompt,
|
||||
normalizeRequiredText,
|
||||
normalizeShortOption,
|
||||
normalizeWorkbenchRequestImageSize,
|
||||
trimToNull,
|
||||
} from './assisted-assembly-ai-render.validation'
|
||||
import {
|
||||
callExternalImageGenerateApi,
|
||||
getExternalImageApiConfig,
|
||||
} from './external-image-generate.service'
|
||||
|
||||
type MaterialReferenceDeliveryMode = 'signed-url' | 'file-upload' | 'mixed' | 'disabled'
|
||||
|
||||
export interface CreateAssistedAssemblyAiRenderInput {
|
||||
prompt: string
|
||||
negativePrompt?: string | null
|
||||
imageCount?: number | null
|
||||
aspectRatio?: string | null
|
||||
imageSize?: string | null
|
||||
mode?: string | null
|
||||
batchId?: string | null
|
||||
viewName?: string | null
|
||||
viewIndex?: number | null
|
||||
workbenchSkuId?: string | null
|
||||
referenceImageDataUrl: string
|
||||
structureReferenceImageDataUrls?: string[] | null
|
||||
materialPartAnnotations?: Array<{
|
||||
nodeNames: string[]
|
||||
instruction: string
|
||||
}> | null
|
||||
workspaceSnapshot: unknown
|
||||
assetSnapshot: unknown
|
||||
}
|
||||
|
||||
function isPrivateIpv4Hostname(hostname: string): boolean {
|
||||
const parts = hostname.split('.').map((part) => Number(part))
|
||||
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
parts[0] === 10 ||
|
||||
parts[0] === 127 ||
|
||||
(parts[0] === 169 && parts[1] === 254) ||
|
||||
(parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) ||
|
||||
(parts[0] === 192 && parts[1] === 168)
|
||||
)
|
||||
}
|
||||
|
||||
function isPubliclyReachableReferenceUrl(value: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(value)
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const hostname = parsed.hostname.trim().toLowerCase()
|
||||
if (!hostname) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
hostname === 'localhost' ||
|
||||
hostname === '0.0.0.0' ||
|
||||
hostname === '127.0.0.1' ||
|
||||
hostname === '::1' ||
|
||||
hostname.endsWith('.local')
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (isPrivateIpv4Hostname(hostname)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function buildEnhancedMaterialReferenceDelivery(
|
||||
entries: MaterialTextureEntry[],
|
||||
): Promise<{
|
||||
referenceImageUrls: string[]
|
||||
additionalReferenceImages: Array<{
|
||||
buffer: Buffer
|
||||
contentType: string
|
||||
fileName: string
|
||||
}>
|
||||
deliveryMode: MaterialReferenceDeliveryMode
|
||||
}> {
|
||||
const referenceImageUrls: string[] = []
|
||||
const additionalReferenceImages: Array<{
|
||||
buffer: Buffer
|
||||
contentType: string
|
||||
fileName: string
|
||||
}> = []
|
||||
|
||||
for (let index = 0; index < entries.length; index += 1) {
|
||||
const entry = entries[index]!
|
||||
let candidateUrl: string | null = null
|
||||
|
||||
if (entry.textureKey) {
|
||||
try {
|
||||
candidateUrl = createObjectReadUrl(entry.textureKey)
|
||||
} catch {
|
||||
candidateUrl = null
|
||||
}
|
||||
}
|
||||
|
||||
if (!candidateUrl && entry.referenceUrl && /^https?:\/\//i.test(entry.referenceUrl)) {
|
||||
candidateUrl = entry.referenceUrl
|
||||
}
|
||||
|
||||
if (candidateUrl && isPubliclyReachableReferenceUrl(candidateUrl)) {
|
||||
referenceImageUrls.push(candidateUrl)
|
||||
continue
|
||||
}
|
||||
|
||||
const sourceUrl =
|
||||
entry.referenceUrl ??
|
||||
candidateUrl ??
|
||||
(entry.textureKey ? createObjectReadUrl(entry.textureKey) : null)
|
||||
|
||||
if (!sourceUrl) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const imageBuffer = await fetchImageBuffer(sourceUrl)
|
||||
additionalReferenceImages.push({
|
||||
buffer: imageBuffer.buffer,
|
||||
contentType: imageBuffer.contentType,
|
||||
fileName: `material-reference-${index + 1}.${imageBuffer.extension}`,
|
||||
})
|
||||
} catch {
|
||||
// Keep AI rendering usable if one material reference cannot be fetched locally.
|
||||
}
|
||||
}
|
||||
|
||||
const deliveryMode =
|
||||
referenceImageUrls.length > 0 && additionalReferenceImages.length > 0
|
||||
? 'mixed'
|
||||
: additionalReferenceImages.length > 0
|
||||
? 'file-upload'
|
||||
: 'signed-url'
|
||||
|
||||
return {
|
||||
referenceImageUrls,
|
||||
additionalReferenceImages,
|
||||
deliveryMode,
|
||||
}
|
||||
}
|
||||
|
||||
function buildAiRenderParams(params: {
|
||||
imageCount: number
|
||||
aspectRatio: string
|
||||
imageSize: string
|
||||
materialReferences: {
|
||||
extracted: number
|
||||
urlReferenced: number
|
||||
fileUploaded: number
|
||||
deliveryMode: MaterialReferenceDeliveryMode
|
||||
}
|
||||
mode?: string
|
||||
batchId?: string | null
|
||||
viewName?: string | null
|
||||
viewIndex?: number | null
|
||||
structureReferenceImages?: Array<{
|
||||
url: string
|
||||
storageKey?: string | null
|
||||
expiresAt?: string | null
|
||||
temporary?: boolean
|
||||
}>
|
||||
materialPartAnnotations?: Array<{
|
||||
nodeNames: string[]
|
||||
instruction: string
|
||||
}>
|
||||
}): Prisma.InputJsonValue {
|
||||
return {
|
||||
imageCount: params.imageCount,
|
||||
aspectRatio: params.aspectRatio,
|
||||
imageSize: params.imageSize,
|
||||
...(params.mode ? { mode: params.mode } : {}),
|
||||
...(params.batchId ? { batchId: params.batchId } : {}),
|
||||
...(params.viewName ? { viewName: params.viewName } : {}),
|
||||
...(typeof params.viewIndex === 'number' ? { viewIndex: params.viewIndex } : {}),
|
||||
...(params.structureReferenceImages && params.structureReferenceImages.length > 0
|
||||
? { structureReferenceImages: params.structureReferenceImages }
|
||||
: {}),
|
||||
...(params.materialPartAnnotations && params.materialPartAnnotations.length > 0
|
||||
? { materialPartAnnotations: params.materialPartAnnotations }
|
||||
: {}),
|
||||
materialReferences: {
|
||||
extracted: params.materialReferences.extracted,
|
||||
urlReferenced: params.materialReferences.urlReferenced,
|
||||
fileUploaded: params.materialReferences.fileUploaded,
|
||||
deliveryMode: params.materialReferences.deliveryMode,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function persistTemporaryWorkbenchResultImages(params: {
|
||||
images: AssistedAssemblyAiRenderImageDTO[]
|
||||
createdBy: string
|
||||
mode?: string | null
|
||||
}): Promise<AssistedAssemblyAiRenderImageDTO[]> {
|
||||
const expiresAt = getTemporaryRenderExpiresAt()
|
||||
const storedImages: AssistedAssemblyAiRenderImageDTO[] = []
|
||||
|
||||
for (let index = 0; index < params.images.length; index += 1) {
|
||||
const image = params.images[index]!
|
||||
const sourceUrl = trimToNull(image.originalUrl)
|
||||
if (!sourceUrl) continue
|
||||
|
||||
const fetchedImage = await fetchImageBuffer(sourceUrl)
|
||||
const cleanedImageBuffer = shouldCleanWorkbenchResultBackground(params.mode)
|
||||
? await cleanWorkbenchResultBackground(fetchedImage)
|
||||
: fetchedImage
|
||||
const imageBuffer = await normalizeWorkbenchResultOutputSize({
|
||||
buffer: cleanedImageBuffer.buffer,
|
||||
extension: cleanedImageBuffer.extension,
|
||||
})
|
||||
const upload = await uploadTemporaryWorkbenchResultImage({
|
||||
buffer: imageBuffer.buffer,
|
||||
contentType: imageBuffer.contentType,
|
||||
extension: imageBuffer.extension,
|
||||
createdBy: params.createdBy,
|
||||
index,
|
||||
})
|
||||
|
||||
storedImages.push({
|
||||
...(image.id !== undefined ? { id: image.id } : {}),
|
||||
originalUrl: upload.url,
|
||||
thumbnailUrl: upload.url,
|
||||
imageSize:
|
||||
imageBuffer.width && imageBuffer.height
|
||||
? `${imageBuffer.width}x${imageBuffer.height}`
|
||||
: image.imageSize ?? null,
|
||||
storageKey: upload.storageKey,
|
||||
expiresAt,
|
||||
temporary: true,
|
||||
})
|
||||
}
|
||||
|
||||
return storedImages
|
||||
}
|
||||
|
||||
export async function createAssistedAssemblyAiRender(
|
||||
input: CreateAssistedAssemblyAiRenderInput,
|
||||
user: AuthUser,
|
||||
): Promise<AssistedAssemblyAiRenderDTO> {
|
||||
const startedAt = Date.now()
|
||||
const createdBy = normalizeRequiredText(user.id, '用户 ID')
|
||||
const prompt = normalizePrompt(input.prompt)
|
||||
const negativePrompt = normalizeNegativePrompt(input.negativePrompt)
|
||||
const imageCount = normalizeImageCount(input.imageCount)
|
||||
const aspectRatio = normalizeShortOption(input.aspectRatio, '16:9', '宽高比')
|
||||
let imageSize = normalizeShortOption(input.imageSize, '2K', '图片规格')
|
||||
const mode = normalizeShortOption(input.mode, '', '生图模式')
|
||||
const batchId = normalizeOptionalText(input.batchId, '图集批次', 160)
|
||||
const viewName = normalizeOptionalText(input.viewName, '视图名称', 32)
|
||||
const viewIndex = normalizeOptionalViewIndex(input.viewIndex)
|
||||
const workbenchSkuId = trimToNull(input.workbenchSkuId)
|
||||
const workbenchSku = workbenchSkuId
|
||||
? await aiRenderWorkbenchService.getSkuForRender(workbenchSkuId, user)
|
||||
: null
|
||||
imageSize = normalizeWorkbenchRequestImageSize(imageSize, Boolean(workbenchSku))
|
||||
const referenceImage = decodeReferenceImageDataUrl(input.referenceImageDataUrl)
|
||||
const structureReferenceImages = workbenchSku
|
||||
? decodeReferenceImageDataUrls(input.structureReferenceImageDataUrls)
|
||||
: []
|
||||
const materialPartAnnotations = workbenchSku
|
||||
? normalizeMaterialPartAnnotations(input.materialPartAnnotations)
|
||||
: []
|
||||
const materialEntries = extractEnhancedMaterialEntriesFromSnapshot(input.assetSnapshot)
|
||||
const materialReferenceStats: {
|
||||
extracted: number
|
||||
urlReferenced: number
|
||||
fileUploaded: number
|
||||
deliveryMode: MaterialReferenceDeliveryMode
|
||||
} = {
|
||||
extracted: materialEntries.length,
|
||||
urlReferenced: 0,
|
||||
fileUploaded: 0,
|
||||
deliveryMode: mode === 'white-background' ? 'disabled' : 'signed-url',
|
||||
}
|
||||
const structureReferenceUploads: Array<{
|
||||
url: string
|
||||
storageKey: string
|
||||
expiresAt: string
|
||||
temporary: true
|
||||
}> = []
|
||||
const structureReferenceExpiresAt = getTemporaryRenderExpiresAt()
|
||||
let referenceUpload: { url: string; storageKey: string } | null = null
|
||||
|
||||
const buildCurrentParams = () =>
|
||||
buildAiRenderParams({
|
||||
imageCount,
|
||||
aspectRatio,
|
||||
imageSize,
|
||||
materialReferences: materialReferenceStats,
|
||||
...(mode ? { mode } : {}),
|
||||
...(batchId ? { batchId } : {}),
|
||||
...(viewName ? { viewName } : {}),
|
||||
...(typeof viewIndex === 'number' ? { viewIndex } : {}),
|
||||
...(structureReferenceUploads.length > 0
|
||||
? { structureReferenceImages: structureReferenceUploads }
|
||||
: {}),
|
||||
...(materialPartAnnotations.length > 0
|
||||
? { materialPartAnnotations }
|
||||
: {}),
|
||||
})
|
||||
|
||||
const buildBaseCreateData = (referenceImageUrl: string) => ({
|
||||
prompt,
|
||||
negativePrompt,
|
||||
params: buildCurrentParams(),
|
||||
referenceImageUrl,
|
||||
resultImages: [] as unknown as Prisma.InputJsonValue,
|
||||
createdBy,
|
||||
workbenchSpuId: workbenchSku?.spuId ?? null,
|
||||
workbenchSkuId: workbenchSku?.id ?? null,
|
||||
workspaceSnapshot: ensureJsonObject(input.workspaceSnapshot, '缺少辅助装配工作台快照'),
|
||||
assetSnapshot: ensureJsonObject(input.assetSnapshot, '缺少辅助装配物料快照'),
|
||||
status: AssistedAssemblyAiRenderStatus.PENDING,
|
||||
})
|
||||
|
||||
try {
|
||||
referenceUpload = workbenchSku
|
||||
? await uploadTemporaryWorkbenchReferenceImage({
|
||||
buffer: referenceImage.buffer,
|
||||
contentType: referenceImage.contentType,
|
||||
extension: referenceImage.extension,
|
||||
createdBy,
|
||||
})
|
||||
: await uploadBufferToObjectStorageWithMetadata(
|
||||
referenceImage.buffer,
|
||||
`assisted-assembly/ai-renders/${createdBy}/${Date.now()}-${nanoid(8)}.${referenceImage.extension}`,
|
||||
referenceImage.contentType,
|
||||
)
|
||||
|
||||
for (let index = 0; index < structureReferenceImages.length; index += 1) {
|
||||
const image = structureReferenceImages[index]!
|
||||
const upload = await uploadTemporaryWorkbenchReferenceImage({
|
||||
buffer: image.buffer,
|
||||
contentType: image.contentType,
|
||||
extension: image.extension,
|
||||
createdBy,
|
||||
index,
|
||||
})
|
||||
structureReferenceUploads.push({
|
||||
url: upload.url,
|
||||
storageKey: upload.storageKey,
|
||||
expiresAt: structureReferenceExpiresAt,
|
||||
temporary: true,
|
||||
})
|
||||
}
|
||||
|
||||
const config = await getExternalImageApiConfig()
|
||||
if (!config.enabled || !config.baseUrl || !config.apiKey) {
|
||||
const record = await prisma.assistedAssemblyAiRender.create({
|
||||
data: {
|
||||
...buildBaseCreateData(referenceUpload.url),
|
||||
params: buildCurrentParams(),
|
||||
status: AssistedAssemblyAiRenderStatus.FAILED,
|
||||
errorMessage: '辅助装配 AI 生图服务未启用或未配置',
|
||||
durationMs: Date.now() - startedAt,
|
||||
},
|
||||
})
|
||||
return toAiRenderDTO(record, user.name)
|
||||
}
|
||||
|
||||
const includeMaterialReferenceAssets = mode !== 'white-background'
|
||||
const materialSuffix = buildEnhancedMaterialMappingPromptSuffix(
|
||||
materialEntries,
|
||||
structureReferenceUploads.length,
|
||||
includeMaterialReferenceAssets,
|
||||
)
|
||||
const enhancedPrompt = materialSuffix ? `${prompt}${materialSuffix}` : prompt
|
||||
|
||||
const materialReferenceDelivery = includeMaterialReferenceAssets
|
||||
? await buildEnhancedMaterialReferenceDelivery(materialEntries)
|
||||
: {
|
||||
referenceImageUrls: [],
|
||||
additionalReferenceImages: [],
|
||||
deliveryMode: 'disabled' as const,
|
||||
}
|
||||
materialReferenceStats.urlReferenced = materialReferenceDelivery.referenceImageUrls.length
|
||||
materialReferenceStats.fileUploaded = materialReferenceDelivery.additionalReferenceImages.length
|
||||
materialReferenceStats.deliveryMode = materialReferenceDelivery.deliveryMode
|
||||
const additionalReferenceImages = [
|
||||
...structureReferenceImages.map((image, index) => ({
|
||||
...image,
|
||||
fileName: `structure-reference-${index + 1}.${image.extension}`,
|
||||
})),
|
||||
...materialReferenceDelivery.additionalReferenceImages,
|
||||
]
|
||||
|
||||
const result = await callExternalImageGenerateApi({
|
||||
prompt: enhancedPrompt,
|
||||
negativePrompt,
|
||||
imageCount,
|
||||
aspectRatio,
|
||||
imageSize,
|
||||
referenceImage: {
|
||||
...referenceImage,
|
||||
fileName: `assembly-reference.${referenceImage.extension}`,
|
||||
},
|
||||
additionalReferenceImages:
|
||||
additionalReferenceImages.length > 0 ? additionalReferenceImages : undefined,
|
||||
referenceImageUrls:
|
||||
materialReferenceDelivery.referenceImageUrls.length > 0
|
||||
? materialReferenceDelivery.referenceImageUrls
|
||||
: undefined,
|
||||
disabledMessage: '辅助装配 AI 生图服务未启用或未配置',
|
||||
})
|
||||
|
||||
const resultImages = workbenchSku
|
||||
? await persistTemporaryWorkbenchResultImages({
|
||||
images: normalizeResultImages(result.images),
|
||||
createdBy,
|
||||
mode,
|
||||
})
|
||||
: normalizeResultImages(result.images)
|
||||
|
||||
const status =
|
||||
resultImages.length > 0
|
||||
? AssistedAssemblyAiRenderStatus.SUCCEEDED
|
||||
: AssistedAssemblyAiRenderStatus.FAILED
|
||||
|
||||
const record = await prisma.assistedAssemblyAiRender.create({
|
||||
data: {
|
||||
...buildBaseCreateData(referenceUpload.url),
|
||||
params: buildCurrentParams(),
|
||||
status,
|
||||
externalRequestId: result.requestId,
|
||||
resultImages: resultImages as unknown as Prisma.InputJsonValue,
|
||||
attempts: result.attempts as unknown as Prisma.InputJsonValue,
|
||||
errorMessage: status === AssistedAssemblyAiRenderStatus.FAILED ? '上游未返回可用图片' : null,
|
||||
durationMs: Date.now() - startedAt,
|
||||
},
|
||||
})
|
||||
return toAiRenderDTO(record, user.name)
|
||||
} catch (error) {
|
||||
if (!referenceUpload) {
|
||||
throw error
|
||||
}
|
||||
|
||||
const record = await prisma.assistedAssemblyAiRender.create({
|
||||
data: {
|
||||
...buildBaseCreateData(referenceUpload.url),
|
||||
params: buildCurrentParams(),
|
||||
status: AssistedAssemblyAiRenderStatus.FAILED,
|
||||
errorMessage: error instanceof Error ? error.message : 'AI 生图失败',
|
||||
durationMs: Date.now() - startedAt,
|
||||
},
|
||||
})
|
||||
return toAiRenderDTO(record, user.name)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,408 @@
|
||||
import { readObjectStorageKeyFromUrl } from '@/lib/storage'
|
||||
import { toRecord, trimToNull } from './assisted-assembly-ai-render.validation'
|
||||
|
||||
const MAX_MATERIAL_TEXTURES = 6
|
||||
|
||||
export interface MaterialTextureEntry {
|
||||
materialId: string
|
||||
materialName: string
|
||||
categoryName?: string | null
|
||||
textureKey: string | null
|
||||
referenceUrl?: string | null
|
||||
nodeNames: string[]
|
||||
color?: string | null
|
||||
metalness?: number | null
|
||||
roughness?: number | null
|
||||
opacity?: number | null
|
||||
transmission?: number | null
|
||||
clearcoat?: number | null
|
||||
hasNormalMap?: boolean
|
||||
hasRoughnessMap?: boolean
|
||||
hasMetalnessMap?: boolean
|
||||
hasAoMap?: boolean
|
||||
}
|
||||
|
||||
function readTextureStorageKey(textureUrl: string): string | null {
|
||||
try {
|
||||
return readObjectStorageKeyFromUrl(textureUrl)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function appendDistinctText(target: string[], value: string | null | undefined): void {
|
||||
const normalized = trimToNull(value)
|
||||
if (normalized && !target.includes(normalized)) {
|
||||
target.push(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOptionalFiniteNumber(value: unknown): number | null {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return null
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function resolveMaterialReferenceSource(materialConfig: Record<string, unknown>): {
|
||||
dedupeKey: string
|
||||
textureKey: string | null
|
||||
referenceUrl: string
|
||||
} | null {
|
||||
const candidates = [materialConfig.textureUrl, materialConfig.thumbnailUrl]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const normalized = trimToNull(typeof candidate === 'string' ? candidate : null)
|
||||
if (!normalized) continue
|
||||
|
||||
const textureKey = readTextureStorageKey(normalized)
|
||||
if (textureKey) {
|
||||
return {
|
||||
dedupeKey: `storage:${textureKey}`,
|
||||
textureKey,
|
||||
referenceUrl: normalized,
|
||||
}
|
||||
}
|
||||
|
||||
if (/^https?:\/\//i.test(normalized)) {
|
||||
return {
|
||||
dedupeKey: `url:${normalized}`,
|
||||
textureKey: null,
|
||||
referenceUrl: normalized,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeHexColor(value: string | null): string | null {
|
||||
const normalized = trimToNull(value)
|
||||
if (!normalized) return null
|
||||
|
||||
const prefixed = normalized.startsWith('#') ? normalized : `#${normalized}`
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(prefixed)) {
|
||||
return prefixed.toUpperCase()
|
||||
}
|
||||
if (/^#[0-9a-fA-F]{3}$/.test(prefixed)) {
|
||||
const expanded = prefixed
|
||||
.slice(1)
|
||||
.split('')
|
||||
.map((char) => `${char}${char}`)
|
||||
.join('')
|
||||
return `#${expanded.toUpperCase()}`
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function describeHexColor(hexColor: string): string {
|
||||
const red = Number.parseInt(hexColor.slice(1, 3), 16) / 255
|
||||
const green = Number.parseInt(hexColor.slice(3, 5), 16) / 255
|
||||
const blue = Number.parseInt(hexColor.slice(5, 7), 16) / 255
|
||||
|
||||
const max = Math.max(red, green, blue)
|
||||
const min = Math.min(red, green, blue)
|
||||
const lightness = (max + min) / 2
|
||||
const delta = max - min
|
||||
const saturation = delta === 0 ? 0 : delta / (1 - Math.abs(2 * lightness - 1))
|
||||
|
||||
let hue = 0
|
||||
if (delta !== 0) {
|
||||
if (max === red) {
|
||||
hue = 60 * (((green - blue) / delta) % 6)
|
||||
} else if (max === green) {
|
||||
hue = 60 * (((blue - red) / delta) + 2)
|
||||
} else {
|
||||
hue = 60 * (((red - green) / delta) + 4)
|
||||
}
|
||||
}
|
||||
if (hue < 0) hue += 360
|
||||
|
||||
const tone =
|
||||
lightness < 0.16
|
||||
? 'very dark'
|
||||
: lightness < 0.34
|
||||
? 'dark'
|
||||
: lightness < 0.68
|
||||
? 'mid-tone'
|
||||
: 'light'
|
||||
const saturationLabel = saturation < 0.16 ? 'muted' : saturation < 0.5 ? 'balanced' : 'rich'
|
||||
|
||||
let family = 'neutral'
|
||||
if (saturation >= 0.08) {
|
||||
if (hue < 20 || hue >= 345) family = 'red'
|
||||
else if (hue < 45) family = 'orange'
|
||||
else if (hue < 70) family = 'yellow'
|
||||
else if (hue < 160) family = 'green'
|
||||
else if (hue < 200) family = 'teal'
|
||||
else if (hue < 255) family = 'blue'
|
||||
else if (hue < 320) family = 'purple'
|
||||
else family = 'pink'
|
||||
} else if (lightness < 0.14) {
|
||||
family = 'near-black'
|
||||
} else if (lightness > 0.86) {
|
||||
family = 'near-white'
|
||||
} else {
|
||||
family = 'gray'
|
||||
}
|
||||
|
||||
return `${tone} ${saturationLabel} ${family}`
|
||||
}
|
||||
|
||||
function inferEnhancedMaterialFamily(entry: MaterialTextureEntry):
|
||||
| 'wood'
|
||||
| 'metal'
|
||||
| 'glass'
|
||||
| 'stone'
|
||||
| 'leather'
|
||||
| 'fabric'
|
||||
| 'solid' {
|
||||
const text = `${entry.materialName} ${entry.categoryName ?? ''}`.toLowerCase()
|
||||
|
||||
if ((entry.transmission ?? 0) > 0.35 || (entry.opacity ?? 1) < 0.72) {
|
||||
return 'glass'
|
||||
}
|
||||
|
||||
if ((entry.metalness ?? 0) >= 0.65 || entry.hasMetalnessMap) {
|
||||
return 'metal'
|
||||
}
|
||||
|
||||
if (/(wood|oak|walnut|ash|teak|pine|maple|cherry|veneer|grain|木|橡木|胡桃木|榆木|樱桃木|松木|榉木|白蜡木)/.test(text)) {
|
||||
return 'wood'
|
||||
}
|
||||
|
||||
if (/(stone|marble|granite|slate|travertine|terrazzo|concrete|石|岩|大理石|花岗岩|砂岩)/.test(text)) {
|
||||
return 'stone'
|
||||
}
|
||||
|
||||
if (/(leather|suede|皮|皮革|仿皮)/.test(text)) {
|
||||
return 'leather'
|
||||
}
|
||||
|
||||
if (/(fabric|linen|cotton|velvet|textile|woven|布|绒|棉|麻|织物|网布)/.test(text)) {
|
||||
return 'fabric'
|
||||
}
|
||||
|
||||
if (/(glass|acrylic|玻璃|亚克力)/.test(text)) {
|
||||
return 'glass'
|
||||
}
|
||||
|
||||
if (/(metal|steel|stainless|aluminum|brass|iron|铜|铁|钢|铝|金属|不锈钢)/.test(text)) {
|
||||
return 'metal'
|
||||
}
|
||||
|
||||
return 'solid'
|
||||
}
|
||||
|
||||
function describeMaterialFinish(entry: MaterialTextureEntry, family: ReturnType<typeof inferEnhancedMaterialFamily>): string | null {
|
||||
const roughness = entry.roughness
|
||||
const transmission = entry.transmission ?? 0
|
||||
const opacity = entry.opacity ?? 1
|
||||
const clearcoat = entry.clearcoat ?? 0
|
||||
const metalness = entry.metalness ?? (entry.hasMetalnessMap ? 1 : 0)
|
||||
|
||||
if (family === 'glass' || transmission > 0.35 || opacity < 0.72) {
|
||||
if (roughness != null && roughness > 0.35) {
|
||||
return 'frosted translucent finish'
|
||||
}
|
||||
return 'clear glossy translucent finish'
|
||||
}
|
||||
|
||||
if (family === 'metal' || metalness >= 0.65 || entry.hasMetalnessMap) {
|
||||
if (roughness == null) {
|
||||
return 'metal finish'
|
||||
}
|
||||
if (roughness <= 0.15) {
|
||||
return 'polished reflective metal finish'
|
||||
}
|
||||
if (roughness <= 0.35) {
|
||||
return 'satin metal finish'
|
||||
}
|
||||
return 'matte brushed metal finish'
|
||||
}
|
||||
|
||||
if (roughness == null) {
|
||||
return clearcoat > 0.35 ? 'clearcoated finish' : null
|
||||
}
|
||||
|
||||
if (roughness >= 0.72) {
|
||||
return 'matte finish'
|
||||
}
|
||||
if (roughness >= 0.45) {
|
||||
return clearcoat > 0.35 ? 'soft satin finish with a light clearcoat' : 'soft satin finish'
|
||||
}
|
||||
if (roughness >= 0.25) {
|
||||
return clearcoat > 0.35 ? 'semi-gloss finish with a clearcoat sheen' : 'semi-gloss finish'
|
||||
}
|
||||
|
||||
return clearcoat > 0.35 ? 'glossy lacquered finish' : 'glossy finish'
|
||||
}
|
||||
|
||||
function describeMaterialPattern(entry: MaterialTextureEntry, family: ReturnType<typeof inferEnhancedMaterialFamily>): string | null {
|
||||
if (entry.referenceUrl || entry.textureKey) {
|
||||
if (family === 'wood') {
|
||||
return 'keep the wood grain direction and scale from the reference'
|
||||
}
|
||||
if (family === 'stone') {
|
||||
return 'keep the stone pattern and veining from the reference'
|
||||
}
|
||||
if (family === 'fabric') {
|
||||
return 'keep the fabric weave from the reference'
|
||||
}
|
||||
if (family === 'leather') {
|
||||
return 'keep the leather grain from the reference'
|
||||
}
|
||||
if (family === 'metal') {
|
||||
return 'keep the surface tone and texture from the reference'
|
||||
}
|
||||
return 'keep the surface pattern from the reference'
|
||||
}
|
||||
|
||||
if (entry.hasNormalMap || entry.hasRoughnessMap || entry.hasAoMap) {
|
||||
return 'preserve fine surface relief'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function buildEnhancedMaterialDescriptor(entry: MaterialTextureEntry): string {
|
||||
const parts: string[] = []
|
||||
const family = inferEnhancedMaterialFamily(entry)
|
||||
const normalizedHexColor = normalizeHexColor(entry.color ?? null)
|
||||
const normalizedColor = trimToNull(entry.color)
|
||||
|
||||
if (normalizedHexColor) {
|
||||
parts.push(`base color ${normalizedHexColor} (${describeHexColor(normalizedHexColor)})`)
|
||||
} else if (normalizedColor) {
|
||||
parts.push(`base color ${normalizedColor}`)
|
||||
}
|
||||
|
||||
if (family === 'wood') {
|
||||
parts.push('wood finish')
|
||||
} else if (family === 'metal') {
|
||||
parts.push('metal finish')
|
||||
} else if (family === 'glass') {
|
||||
parts.push('glass-like finish')
|
||||
} else if (family === 'stone') {
|
||||
parts.push('stone finish')
|
||||
} else if (family === 'leather') {
|
||||
parts.push('leather finish')
|
||||
} else if (family === 'fabric') {
|
||||
parts.push('fabric finish')
|
||||
}
|
||||
|
||||
const finish = describeMaterialFinish(entry, family)
|
||||
if (finish) {
|
||||
parts.push(finish)
|
||||
}
|
||||
|
||||
const pattern = describeMaterialPattern(entry, family)
|
||||
if (pattern) {
|
||||
parts.push(pattern)
|
||||
}
|
||||
|
||||
if (
|
||||
(entry.hasNormalMap || entry.hasRoughnessMap || entry.hasAoMap) &&
|
||||
!parts.includes('preserve fine surface relief')
|
||||
) {
|
||||
parts.push('preserve fine surface relief')
|
||||
}
|
||||
|
||||
return parts.join(', ')
|
||||
}
|
||||
|
||||
export function extractEnhancedMaterialEntriesFromSnapshot(assetSnapshot: unknown): MaterialTextureEntry[] {
|
||||
const snapshot = toRecord(assetSnapshot)
|
||||
const nodes = Array.isArray(snapshot.nodes) ? snapshot.nodes : []
|
||||
const materialEntries: MaterialTextureEntry[] = []
|
||||
const materialMap = new Map<string, MaterialTextureEntry>()
|
||||
|
||||
for (const rawNode of nodes) {
|
||||
const node = toRecord(rawNode)
|
||||
const materialConfig = toRecord(node.materialConfig)
|
||||
const nodeName = typeof node.name === 'string' ? node.name : null
|
||||
const materialId = trimToNull(typeof materialConfig.id === 'string' ? materialConfig.id : null) ?? 'material-unknown'
|
||||
const materialName = trimToNull(typeof materialConfig.name === 'string' ? materialConfig.name : null) ?? materialId
|
||||
const fallbackKey = `material:${materialId}`
|
||||
const referenceSource = resolveMaterialReferenceSource(materialConfig)
|
||||
const dedupeKey = referenceSource?.dedupeKey ?? fallbackKey
|
||||
const existing = materialMap.get(dedupeKey) ?? materialMap.get(fallbackKey)
|
||||
|
||||
if (existing) {
|
||||
appendDistinctText(existing.nodeNames, nodeName)
|
||||
if (!existing.referenceUrl && referenceSource?.referenceUrl) {
|
||||
existing.referenceUrl = referenceSource.referenceUrl
|
||||
existing.textureKey = referenceSource.textureKey
|
||||
}
|
||||
materialMap.set(dedupeKey, existing)
|
||||
materialMap.set(fallbackKey, existing)
|
||||
continue
|
||||
}
|
||||
|
||||
const entry: MaterialTextureEntry = {
|
||||
materialId,
|
||||
materialName,
|
||||
categoryName: trimToNull(typeof materialConfig.categoryName === 'string' ? materialConfig.categoryName : null),
|
||||
textureKey: referenceSource?.textureKey ?? null,
|
||||
referenceUrl: referenceSource?.referenceUrl ?? null,
|
||||
nodeNames: nodeName ? [nodeName] : [],
|
||||
color: trimToNull(typeof materialConfig.color === 'string' ? materialConfig.color : null),
|
||||
metalness: normalizeOptionalFiniteNumber(materialConfig.metalness),
|
||||
roughness: normalizeOptionalFiniteNumber(materialConfig.roughness),
|
||||
opacity: normalizeOptionalFiniteNumber(materialConfig.opacity),
|
||||
transmission: normalizeOptionalFiniteNumber(materialConfig.transmission),
|
||||
clearcoat: normalizeOptionalFiniteNumber(materialConfig.clearcoat),
|
||||
hasNormalMap: Boolean(trimToNull(typeof materialConfig.normalMapUrl === 'string' ? materialConfig.normalMapUrl : null)),
|
||||
hasRoughnessMap: Boolean(trimToNull(typeof materialConfig.roughnessMapUrl === 'string' ? materialConfig.roughnessMapUrl : null)),
|
||||
hasMetalnessMap: Boolean(trimToNull(typeof materialConfig.metalnessMapUrl === 'string' ? materialConfig.metalnessMapUrl : null)),
|
||||
hasAoMap: Boolean(trimToNull(typeof materialConfig.aoMapUrl === 'string' ? materialConfig.aoMapUrl : null)),
|
||||
}
|
||||
|
||||
materialEntries.push(entry)
|
||||
materialMap.set(dedupeKey, entry)
|
||||
materialMap.set(fallbackKey, entry)
|
||||
}
|
||||
|
||||
return materialEntries.slice(0, MAX_MATERIAL_TEXTURES)
|
||||
}
|
||||
|
||||
export function buildEnhancedMaterialMappingPromptSuffix(
|
||||
entries: MaterialTextureEntry[],
|
||||
structureReferenceCount = 0,
|
||||
includeReferenceLabels = true,
|
||||
): string {
|
||||
if (entries.length === 0) return ''
|
||||
|
||||
const hasWoodMaterial = entries.some((entry) => inferEnhancedMaterialFamily(entry) === 'wood')
|
||||
let nextReferenceIndex = 2 + Math.max(0, structureReferenceCount)
|
||||
|
||||
const lines = entries.map((entry, index) => {
|
||||
const partDesc =
|
||||
entry.nodeNames.length > 0
|
||||
? entry.nodeNames.map((name) => `"${name}"`).join(', ')
|
||||
: `part #${index + 1}`
|
||||
const descriptor = buildEnhancedMaterialDescriptor(entry)
|
||||
const referenceLabel =
|
||||
includeReferenceLabels && (entry.referenceUrl || entry.textureKey)
|
||||
? `[Reference #${nextReferenceIndex++}] `
|
||||
: ''
|
||||
const materialLabel = entry.categoryName
|
||||
? `${entry.materialName} (${entry.categoryName})`
|
||||
: entry.materialName
|
||||
return `- ${referenceLabel}${materialLabel} -> apply to: ${partDesc}; ${descriptor}`
|
||||
})
|
||||
|
||||
const globalRules = [
|
||||
'Match each assigned material closely and preserve the same finish color, wood tone, grain scale, saturation, brightness, gloss level, and hardware color as the references.',
|
||||
'Do not shift the material warmer or cooler, darker or lighter, or more saturated or desaturated than the reference unless the user explicitly asks for that change.',
|
||||
'Keep subtle reflections and sheen; do not make satin or glossy materials look chalky, flat, or plastic.',
|
||||
'Keep different part groups separated and do not let one finish bleed into another part.',
|
||||
]
|
||||
|
||||
if (hasWoodMaterial) {
|
||||
globalRules.push('Do not warm neutral or dark wood finishes toward orange or red unless the reference itself is clearly warm.')
|
||||
}
|
||||
|
||||
return `\n\nMaterial fidelity rules:\n- ${globalRules.join('\n- ')}\nMaterial mapping for this product:\n${lines.join('\n')}`
|
||||
}
|
||||
@ -0,0 +1,207 @@
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import { AssistedAssemblyAiRenderStatus } from '@prisma/client'
|
||||
import {
|
||||
deleteWorkbenchRenderStorageKey,
|
||||
isTemporaryWorkbenchRenderKey,
|
||||
normalizeTemporaryReferenceImages,
|
||||
readImageStorageKey,
|
||||
type WorkbenchRenderStorageDeleteResult,
|
||||
} from './assisted-assembly-ai-render.temp-storage'
|
||||
import { toRecord, trimToNull } from './assisted-assembly-ai-render.validation'
|
||||
|
||||
export interface AssistedAssemblyAiRenderDTO {
|
||||
id: string
|
||||
workbenchSpuId: string | null
|
||||
workbenchSkuId: string | null
|
||||
prompt: string
|
||||
negativePrompt: string | null
|
||||
params: Record<string, unknown>
|
||||
referenceImageUrl: string | null
|
||||
resultImages: AssistedAssemblyAiRenderImageDTO[]
|
||||
workspaceSnapshot: unknown
|
||||
assetSnapshot: unknown
|
||||
externalRequestId: string | null
|
||||
attempts: unknown
|
||||
status: AssistedAssemblyAiRenderStatus
|
||||
errorMessage: string | null
|
||||
durationMs: number | null
|
||||
restoreUrl: string
|
||||
creatorName: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface AssistedAssemblyAiRenderImageDTO {
|
||||
id?: string | number
|
||||
originalUrl: string
|
||||
thumbnailUrl?: string | null
|
||||
imageSize?: string | null
|
||||
storageKey?: string | null
|
||||
expiresAt?: string | null
|
||||
temporary?: boolean
|
||||
}
|
||||
|
||||
function readRenderImageStorageKey(image: AssistedAssemblyAiRenderImageDTO): string | null {
|
||||
return (
|
||||
trimToNull(image.storageKey) ??
|
||||
readImageStorageKey(image.originalUrl) ??
|
||||
readImageStorageKey(image.thumbnailUrl ?? null)
|
||||
)
|
||||
}
|
||||
|
||||
function collectRenderStorageKeys(record: {
|
||||
referenceImageUrl: string | null
|
||||
resultImages: Prisma.JsonValue
|
||||
params?: Prisma.JsonValue | null
|
||||
}): string[] {
|
||||
const keys = new Set<string>()
|
||||
const referenceKey = readImageStorageKey(record.referenceImageUrl)
|
||||
if (referenceKey) keys.add(referenceKey)
|
||||
|
||||
normalizeResultImages(record.resultImages).forEach((image) => {
|
||||
const candidateKeys = [
|
||||
image.storageKey,
|
||||
readImageStorageKey(image.originalUrl),
|
||||
readImageStorageKey(image.thumbnailUrl),
|
||||
]
|
||||
candidateKeys.forEach((key) => {
|
||||
if (key) keys.add(key)
|
||||
})
|
||||
})
|
||||
|
||||
const paramsRecord = toRecord(record.params)
|
||||
normalizeTemporaryReferenceImages(paramsRecord.structureReferenceImages).forEach((image) => {
|
||||
const candidateKeys = [image.storageKey, readImageStorageKey(image.url)]
|
||||
candidateKeys.forEach((key) => {
|
||||
if (key && isTemporaryWorkbenchRenderKey(key)) keys.add(key)
|
||||
})
|
||||
})
|
||||
|
||||
return Array.from(keys)
|
||||
}
|
||||
|
||||
export function normalizeResultImages(value: unknown): AssistedAssemblyAiRenderImageDTO[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
const images: AssistedAssemblyAiRenderImageDTO[] = []
|
||||
|
||||
value.forEach((item) => {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
const record = item as Record<string, unknown>
|
||||
const originalUrl =
|
||||
typeof record.originalUrl === 'string'
|
||||
? record.originalUrl
|
||||
: typeof record.url === 'string'
|
||||
? record.url
|
||||
: null
|
||||
if (!originalUrl) {
|
||||
return
|
||||
}
|
||||
|
||||
const thumbnailUrl =
|
||||
typeof record.thumbnailUrl === 'string' ? record.thumbnailUrl : originalUrl
|
||||
const storageKey = trimToNull(
|
||||
typeof record.storageKey === 'string'
|
||||
? record.storageKey
|
||||
: readImageStorageKey(originalUrl) ?? readImageStorageKey(thumbnailUrl),
|
||||
)
|
||||
const temporary =
|
||||
record.temporary === true || Boolean(storageKey && isTemporaryWorkbenchRenderKey(storageKey))
|
||||
|
||||
const id =
|
||||
typeof record.id === 'string' || typeof record.id === 'number'
|
||||
? record.id
|
||||
: undefined
|
||||
|
||||
images.push({
|
||||
...(id !== undefined ? { id } : {}),
|
||||
originalUrl,
|
||||
thumbnailUrl,
|
||||
imageSize: typeof record.imageSize === 'string' ? record.imageSize : null,
|
||||
storageKey,
|
||||
expiresAt: typeof record.expiresAt === 'string' ? trimToNull(record.expiresAt) : null,
|
||||
temporary,
|
||||
})
|
||||
})
|
||||
|
||||
return images
|
||||
}
|
||||
|
||||
export async function deleteRenderImageStorageFile(
|
||||
image: AssistedAssemblyAiRenderImageDTO,
|
||||
): Promise<WorkbenchRenderStorageDeleteResult[]> {
|
||||
const storageKey = readRenderImageStorageKey(image)
|
||||
if (storageKey) {
|
||||
return [await deleteWorkbenchRenderStorageKey(storageKey)]
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
export async function deleteRenderStorageFiles(
|
||||
record: {
|
||||
referenceImageUrl: string | null
|
||||
resultImages: Prisma.JsonValue
|
||||
params?: Prisma.JsonValue | null
|
||||
},
|
||||
options?: { temporaryOnly?: boolean },
|
||||
): Promise<WorkbenchRenderStorageDeleteResult[]> {
|
||||
const keys = collectRenderStorageKeys(record)
|
||||
const results: WorkbenchRenderStorageDeleteResult[] = []
|
||||
for (const key of keys) {
|
||||
if (options?.temporaryOnly && !isTemporaryWorkbenchRenderKey(key)) {
|
||||
continue
|
||||
}
|
||||
results.push(await deleteWorkbenchRenderStorageKey(key))
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
export function toAiRenderDTO(
|
||||
record: {
|
||||
id: string
|
||||
workbenchSpuId: string | null
|
||||
workbenchSkuId: string | null
|
||||
prompt: string
|
||||
negativePrompt: string | null
|
||||
params: Prisma.JsonValue
|
||||
referenceImageUrl: string | null
|
||||
resultImages: Prisma.JsonValue
|
||||
workspaceSnapshot: Prisma.JsonValue
|
||||
assetSnapshot: Prisma.JsonValue
|
||||
externalRequestId: string | null
|
||||
attempts: Prisma.JsonValue | null
|
||||
status: AssistedAssemblyAiRenderStatus
|
||||
errorMessage: string | null
|
||||
durationMs: number | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
creator?: { name: string | null } | null
|
||||
},
|
||||
creatorNameFallback?: string | null,
|
||||
): AssistedAssemblyAiRenderDTO {
|
||||
return {
|
||||
id: record.id,
|
||||
workbenchSpuId: record.workbenchSpuId,
|
||||
workbenchSkuId: record.workbenchSkuId,
|
||||
prompt: record.prompt,
|
||||
negativePrompt: record.negativePrompt,
|
||||
params: toRecord(record.params),
|
||||
referenceImageUrl: record.referenceImageUrl,
|
||||
resultImages: normalizeResultImages(record.resultImages),
|
||||
workspaceSnapshot: record.workspaceSnapshot,
|
||||
assetSnapshot: record.assetSnapshot,
|
||||
externalRequestId: record.externalRequestId,
|
||||
attempts: record.attempts,
|
||||
status: record.status,
|
||||
errorMessage: record.errorMessage,
|
||||
durationMs: record.durationMs,
|
||||
restoreUrl: `/assisted-assembly?aiRenderId=${encodeURIComponent(record.id)}`,
|
||||
creatorName: trimToNull(record.creator?.name ?? creatorNameFallback ?? null),
|
||||
createdAt: record.createdAt.toISOString(),
|
||||
updatedAt: record.updatedAt.toISOString(),
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,573 @@
|
||||
import { mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { nanoid } from 'nanoid'
|
||||
import sharp from 'sharp'
|
||||
import {
|
||||
createObjectReadUrl,
|
||||
deleteObjectWithResult,
|
||||
readObjectStorageKeyFromUrl,
|
||||
type ObjectStorageDeleteResult,
|
||||
uploadBufferToObjectStorageWithMetadata,
|
||||
} from '@/lib/storage'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import {
|
||||
DATA_URL_IMAGE_PATTERN,
|
||||
MAX_STRUCTURE_REFERENCE_IMAGE_COUNT,
|
||||
trimToNull,
|
||||
} from './assisted-assembly-ai-render.validation'
|
||||
|
||||
const LOCAL_WORKBENCH_RENDER_STORAGE_KEY_PREFIX = 'local:'
|
||||
const LOCAL_UPLOADS_ROOT = path.join(process.cwd(), 'public', 'uploads')
|
||||
const LOCAL_UPLOADS_URL_PREFIX = '/uploads/'
|
||||
const WORKBENCH_RESULT_MAX_DIMENSION = 1000
|
||||
const GENERATED_IMAGE_FETCH_TIMEOUT_MS = 60_000
|
||||
|
||||
export const WORKBENCH_RENDER_FILE_RETENTION_DAYS = 3
|
||||
export const WORKBENCH_RENDER_FILE_RETENTION_MS =
|
||||
WORKBENCH_RENDER_FILE_RETENTION_DAYS * 24 * 60 * 60 * 1000
|
||||
export const WORKBENCH_RENDER_STORAGE_PREFIX = 'ai-render-workbench/temp'
|
||||
export const WORKBENCH_RENDER_STORAGE_ENCODED_PREFIX =
|
||||
WORKBENCH_RENDER_STORAGE_PREFIX.replace(/\//g, '%2F')
|
||||
export const WORKBENCH_RENDER_CLEANUP_BATCH_SIZE = 100
|
||||
export const WORKBENCH_RENDER_CLEANUP_MIN_INTERVAL_MS = 5 * 60 * 1000
|
||||
|
||||
let lastWorkbenchDetailReferenceCleanupAt = 0
|
||||
|
||||
export type WorkbenchRenderStorageDeleteResult =
|
||||
| ObjectStorageDeleteResult
|
||||
| {
|
||||
provider: 'local'
|
||||
key: string
|
||||
status: 'deleted' | 'error'
|
||||
message: string | null
|
||||
statusCode: null
|
||||
}
|
||||
|
||||
export interface TemporaryWorkbenchReferenceImage {
|
||||
url: string
|
||||
storageKey: string | null
|
||||
expiresAt: string | null
|
||||
temporary: boolean
|
||||
}
|
||||
|
||||
export interface TemporaryWorkbenchStoredImage {
|
||||
url: string
|
||||
storageKey: string
|
||||
}
|
||||
|
||||
function buildLocalWorkbenchRenderStorageKey(storagePath: string): string {
|
||||
return `${LOCAL_WORKBENCH_RENDER_STORAGE_KEY_PREFIX}${storagePath}`
|
||||
}
|
||||
|
||||
function stripLocalWorkbenchRenderStorageKey(storageKey: string): string | null {
|
||||
const normalized = trimToNull(storageKey)
|
||||
if (!normalized?.startsWith(LOCAL_WORKBENCH_RENDER_STORAGE_KEY_PREFIX)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const storagePath = normalized
|
||||
.slice(LOCAL_WORKBENCH_RENDER_STORAGE_KEY_PREFIX.length)
|
||||
.replace(/^\/+/, '')
|
||||
|
||||
return storagePath || null
|
||||
}
|
||||
|
||||
function readLocalWorkbenchRenderStoragePath(
|
||||
value: string | null | undefined,
|
||||
): string | null {
|
||||
const normalized = trimToNull(value)
|
||||
if (!normalized) return null
|
||||
|
||||
const localStoragePath = stripLocalWorkbenchRenderStorageKey(normalized)
|
||||
if (localStoragePath) {
|
||||
return localStoragePath
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(normalized, 'http://local.render')
|
||||
const pathname = parsed.pathname.replace(/\\/g, '/')
|
||||
if (!pathname.startsWith(LOCAL_UPLOADS_URL_PREFIX)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const storagePath = pathname.slice(LOCAL_UPLOADS_URL_PREFIX.length)
|
||||
return isTemporaryWorkbenchRenderKey(storagePath) ? storagePath : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function readLocalWorkbenchRenderStorageKey(
|
||||
value: string | null | undefined,
|
||||
): string | null {
|
||||
const storagePath = readLocalWorkbenchRenderStoragePath(value)
|
||||
return storagePath ? buildLocalWorkbenchRenderStorageKey(storagePath) : null
|
||||
}
|
||||
|
||||
function getImageExtensionFromContentType(contentType: string): string {
|
||||
const normalized = contentType.toLowerCase()
|
||||
if (normalized.includes('webp')) return 'webp'
|
||||
if (normalized.includes('jpeg') || normalized.includes('jpg')) return 'jpg'
|
||||
return 'png'
|
||||
}
|
||||
|
||||
function getImageExtensionFromUrl(value: string): string | null {
|
||||
try {
|
||||
const parsed = new URL(value, 'http://storage.local')
|
||||
const match = parsed.pathname.match(/\.([a-zA-Z0-9]{2,5})$/)
|
||||
const extension = match?.[1]?.toLowerCase()
|
||||
if (extension === 'jpg' || extension === 'jpeg') return 'jpg'
|
||||
if (extension === 'png' || extension === 'webp') return extension
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function buildTemporaryRenderStorageKey(params: {
|
||||
createdBy: string
|
||||
kind: 'references' | 'results' | 'detail-references'
|
||||
extension: string
|
||||
index?: number
|
||||
}): string {
|
||||
const indexPart = typeof params.index === 'number' ? `-${params.index + 1}` : ''
|
||||
return `${WORKBENCH_RENDER_STORAGE_PREFIX}/${params.createdBy}/${params.kind}/${Date.now()}-${nanoid(8)}${indexPart}.${params.extension}`
|
||||
}
|
||||
|
||||
function resolveLocalWorkbenchRenderAbsolutePath(storagePath: string): string {
|
||||
const normalizedStoragePath = storagePath.trim().replace(/^\/+/, '')
|
||||
if (!isTemporaryWorkbenchRenderKey(normalizedStoragePath)) {
|
||||
throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, 'AI 临时图片存储路径不合法')
|
||||
}
|
||||
|
||||
const absolutePath = path.resolve(LOCAL_UPLOADS_ROOT, normalizedStoragePath)
|
||||
const relativePath = path.relative(LOCAL_UPLOADS_ROOT, absolutePath)
|
||||
if (!relativePath || relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
|
||||
throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, 'AI 临时图片存储路径不合法')
|
||||
}
|
||||
|
||||
return absolutePath
|
||||
}
|
||||
|
||||
async function writeLocalWorkbenchRenderFile(params: {
|
||||
buffer: Buffer
|
||||
storagePath: string
|
||||
}): Promise<TemporaryWorkbenchStoredImage> {
|
||||
const absolutePath = resolveLocalWorkbenchRenderAbsolutePath(params.storagePath)
|
||||
await mkdir(path.dirname(absolutePath), { recursive: true })
|
||||
await writeFile(absolutePath, params.buffer)
|
||||
|
||||
return {
|
||||
url: `${LOCAL_UPLOADS_URL_PREFIX}${params.storagePath}`,
|
||||
storageKey: buildLocalWorkbenchRenderStorageKey(params.storagePath),
|
||||
}
|
||||
}
|
||||
|
||||
async function readLocalWorkbenchRenderFile(storageKey: string): Promise<{
|
||||
buffer: Buffer
|
||||
contentType: string
|
||||
extension: string
|
||||
}> {
|
||||
const storagePath = stripLocalWorkbenchRenderStorageKey(storageKey)
|
||||
if (!storagePath) {
|
||||
throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, 'AI 临时图片存储路径不合法')
|
||||
}
|
||||
|
||||
const absolutePath = resolveLocalWorkbenchRenderAbsolutePath(storagePath)
|
||||
const buffer = await readFile(absolutePath)
|
||||
const extension = getImageExtensionFromUrl(storagePath) ?? 'png'
|
||||
const contentType = `image/${extension === 'jpg' ? 'jpeg' : extension}`
|
||||
return {
|
||||
buffer,
|
||||
contentType,
|
||||
extension,
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadTemporaryWorkbenchImage(params: {
|
||||
buffer: Buffer
|
||||
contentType: string
|
||||
extension: string
|
||||
createdBy: string
|
||||
kind: 'references' | 'results' | 'detail-references'
|
||||
index?: number
|
||||
}): Promise<TemporaryWorkbenchStoredImage> {
|
||||
const storagePath = buildTemporaryRenderStorageKey({
|
||||
createdBy: params.createdBy,
|
||||
kind: params.kind,
|
||||
extension: params.extension,
|
||||
index: params.index,
|
||||
})
|
||||
|
||||
if (shouldUseLocalWorkbenchRenderStorage()) {
|
||||
return writeLocalWorkbenchRenderFile({
|
||||
buffer: params.buffer,
|
||||
storagePath,
|
||||
})
|
||||
}
|
||||
|
||||
return uploadBufferToObjectStorageWithMetadata(
|
||||
params.buffer,
|
||||
storagePath,
|
||||
params.contentType,
|
||||
)
|
||||
}
|
||||
|
||||
async function cleanupExpiredLocalWorkbenchDetailReferenceFiles(): Promise<void> {
|
||||
if (!shouldUseLocalWorkbenchRenderStorage()) {
|
||||
return
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
if (now - lastWorkbenchDetailReferenceCleanupAt < WORKBENCH_RENDER_CLEANUP_MIN_INTERVAL_MS) {
|
||||
return
|
||||
}
|
||||
lastWorkbenchDetailReferenceCleanupAt = now
|
||||
|
||||
const rootDirectory = path.resolve(LOCAL_UPLOADS_ROOT, WORKBENCH_RENDER_STORAGE_PREFIX)
|
||||
const cutoffTime = now - WORKBENCH_RENDER_FILE_RETENTION_MS
|
||||
|
||||
const walk = async (directory: string): Promise<void> => {
|
||||
const entries = await readdir(directory, { withFileTypes: true }).catch(
|
||||
(error: NodeJS.ErrnoException) => {
|
||||
if (error?.code === 'ENOENT') {
|
||||
return []
|
||||
}
|
||||
throw error
|
||||
},
|
||||
)
|
||||
|
||||
for (const entry of entries) {
|
||||
const absolutePath = path.join(directory, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
await walk(absolutePath)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!absolutePath.includes(`${path.sep}detail-references${path.sep}`)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const fileStat = await stat(absolutePath).catch(() => null)
|
||||
if (!fileStat || fileStat.mtimeMs >= cutoffTime) {
|
||||
continue
|
||||
}
|
||||
|
||||
await rm(absolutePath, { force: true }).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
await walk(rootDirectory)
|
||||
}
|
||||
|
||||
export function readImageStorageKey(value: string | null | undefined): string | null {
|
||||
if (!value) return null
|
||||
const localStorageKey = readLocalWorkbenchRenderStorageKey(value)
|
||||
if (localStorageKey) {
|
||||
return localStorageKey
|
||||
}
|
||||
try {
|
||||
return readObjectStorageKeyFromUrl(value)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldUseLocalWorkbenchRenderStorage(): boolean {
|
||||
const configured = trimToNull(process.env.AI_RENDER_LOCAL_FILE_STORAGE)
|
||||
if (configured) {
|
||||
return ['1', 'true', 'yes', 'on'].includes(configured.toLowerCase())
|
||||
}
|
||||
|
||||
return process.env.NODE_ENV === 'development'
|
||||
}
|
||||
|
||||
export function isTemporaryWorkbenchRenderKey(key: string): boolean {
|
||||
const localStoragePath = stripLocalWorkbenchRenderStorageKey(key)
|
||||
const normalized = (localStoragePath ?? key).trim().replace(/^\/+/, '')
|
||||
return (
|
||||
normalized.startsWith(`${WORKBENCH_RENDER_STORAGE_PREFIX}/`) ||
|
||||
normalized.includes(`/${WORKBENCH_RENDER_STORAGE_PREFIX}/`)
|
||||
)
|
||||
}
|
||||
|
||||
export function getTemporaryRenderExpiresAt(): string {
|
||||
return new Date(Date.now() + WORKBENCH_RENDER_FILE_RETENTION_MS).toISOString()
|
||||
}
|
||||
|
||||
export function isWorkbenchDetailReferenceStorageKey(key: string): boolean {
|
||||
const localStoragePath = stripLocalWorkbenchRenderStorageKey(key)
|
||||
const normalized = (localStoragePath ?? key).trim().replace(/^\/+/, '')
|
||||
return (
|
||||
normalized.includes('/detail-references/') ||
|
||||
(normalized.startsWith(`${WORKBENCH_RENDER_STORAGE_PREFIX}/`) &&
|
||||
normalized.includes('/detail-references/'))
|
||||
)
|
||||
}
|
||||
|
||||
export function isWorkbenchTemporaryStorageKeyOwnedByUser(
|
||||
storageKey: string,
|
||||
createdBy: string,
|
||||
): boolean {
|
||||
const localStoragePath = stripLocalWorkbenchRenderStorageKey(storageKey)
|
||||
const normalized = (localStoragePath ?? storageKey).trim().replace(/^\/+/, '')
|
||||
return normalized.startsWith(`${WORKBENCH_RENDER_STORAGE_PREFIX}/${createdBy}/`)
|
||||
}
|
||||
|
||||
export async function cleanupExpiredLocalWorkbenchDetailReferenceFilesSafely(): Promise<void> {
|
||||
try {
|
||||
await cleanupExpiredLocalWorkbenchDetailReferenceFiles()
|
||||
} catch (error) {
|
||||
console.warn('[AiRenderWorkbench] cleanup expired detail reference files failed', error)
|
||||
}
|
||||
}
|
||||
|
||||
export function isSuccessfulWorkbenchRenderStorageDeleteResult(
|
||||
result: WorkbenchRenderStorageDeleteResult,
|
||||
): boolean {
|
||||
return result.status === 'deleted' || result.status === 'missing'
|
||||
}
|
||||
|
||||
export function logWorkbenchRenderStorageDeleteFailures(
|
||||
context: string,
|
||||
results: WorkbenchRenderStorageDeleteResult[],
|
||||
details: Record<string, unknown>,
|
||||
): void {
|
||||
const failures = results
|
||||
.filter((result) => !isSuccessfulWorkbenchRenderStorageDeleteResult(result))
|
||||
.map((result) => ({
|
||||
provider: result.provider,
|
||||
key: result.key,
|
||||
status: result.status,
|
||||
statusCode: result.statusCode,
|
||||
message: result.message,
|
||||
}))
|
||||
|
||||
if (failures.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
console.warn('[AiRenderWorkbench] storage delete failed', {
|
||||
context,
|
||||
...details,
|
||||
failures,
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteWorkbenchRenderStorageKey(
|
||||
storageKey: string,
|
||||
): Promise<WorkbenchRenderStorageDeleteResult> {
|
||||
const localStoragePath = stripLocalWorkbenchRenderStorageKey(storageKey)
|
||||
if (localStoragePath) {
|
||||
try {
|
||||
const absolutePath = resolveLocalWorkbenchRenderAbsolutePath(localStoragePath)
|
||||
await rm(absolutePath, { force: true })
|
||||
return {
|
||||
provider: 'local',
|
||||
key: storageKey,
|
||||
status: 'deleted',
|
||||
message: null,
|
||||
statusCode: null,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
provider: 'local',
|
||||
key: storageKey,
|
||||
status: 'error',
|
||||
message: error instanceof Error ? error.message : '本地 AI 临时图片删除失败',
|
||||
statusCode: null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deleteObjectWithResult(storageKey)
|
||||
}
|
||||
|
||||
export function normalizeTemporaryReferenceImages(
|
||||
value: unknown,
|
||||
): TemporaryWorkbenchReferenceImage[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
|
||||
return value
|
||||
.slice(0, MAX_STRUCTURE_REFERENCE_IMAGE_COUNT)
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== 'object') return null
|
||||
const record = item as Record<string, unknown>
|
||||
const url = trimToNull(typeof record.url === 'string' ? record.url : null)
|
||||
if (!url) return null
|
||||
return {
|
||||
url,
|
||||
storageKey: trimToNull(typeof record.storageKey === 'string' ? record.storageKey : null),
|
||||
expiresAt: trimToNull(typeof record.expiresAt === 'string' ? record.expiresAt : null),
|
||||
temporary: record.temporary === true,
|
||||
}
|
||||
})
|
||||
.filter((item): item is TemporaryWorkbenchReferenceImage => Boolean(item))
|
||||
}
|
||||
|
||||
export async function fetchImageBuffer(imageUrl: string): Promise<{
|
||||
buffer: Buffer
|
||||
contentType: string
|
||||
extension: string
|
||||
}> {
|
||||
const dataUrlMatch = imageUrl.match(DATA_URL_IMAGE_PATTERN)
|
||||
if (dataUrlMatch) {
|
||||
const contentType = dataUrlMatch[1]!.toLowerCase()
|
||||
return {
|
||||
buffer: Buffer.from(dataUrlMatch[2]!, 'base64'),
|
||||
contentType,
|
||||
extension: getImageExtensionFromContentType(contentType),
|
||||
}
|
||||
}
|
||||
|
||||
const storageKey = readImageStorageKey(imageUrl)
|
||||
if (storageKey?.startsWith(LOCAL_WORKBENCH_RENDER_STORAGE_KEY_PREFIX)) {
|
||||
return readLocalWorkbenchRenderFile(storageKey)
|
||||
}
|
||||
const fetchUrl = storageKey ? createObjectReadUrl(storageKey) : imageUrl
|
||||
if (!/^https?:\/\//i.test(fetchUrl)) {
|
||||
throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, 'AI 结果图地址不可读取')
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), GENERATED_IMAGE_FETCH_TIMEOUT_MS)
|
||||
try {
|
||||
const response = await fetch(fetchUrl, { signal: controller.signal })
|
||||
if (!response.ok) {
|
||||
throw new AppError(ErrorCode.FILE_UPLOAD_FAILED, `AI 结果图下载失败(${response.status})`)
|
||||
}
|
||||
const contentType =
|
||||
response.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase() || ''
|
||||
const extension = contentType.startsWith('image/')
|
||||
? getImageExtensionFromContentType(contentType)
|
||||
: getImageExtensionFromUrl(fetchUrl) ?? 'png'
|
||||
const normalizedContentType = contentType.startsWith('image/')
|
||||
? contentType
|
||||
: `image/${extension === 'jpg' ? 'jpeg' : extension}`
|
||||
return {
|
||||
buffer: Buffer.from(await response.arrayBuffer()),
|
||||
contentType: normalizedContentType,
|
||||
extension,
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new AppError(ErrorCode.FILE_UPLOAD_FAILED, 'AI 结果图下载超时,请稍后重试')
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
export async function normalizeWorkbenchResultOutputSize(params: {
|
||||
buffer: Buffer
|
||||
extension: string
|
||||
}): Promise<{
|
||||
buffer: Buffer
|
||||
extension: string
|
||||
contentType: string
|
||||
width: number | null
|
||||
height: number | null
|
||||
}> {
|
||||
const pipeline = sharp(params.buffer)
|
||||
.rotate()
|
||||
.resize({
|
||||
width: WORKBENCH_RESULT_MAX_DIMENSION,
|
||||
height: WORKBENCH_RESULT_MAX_DIMENSION,
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.toColorspace('srgb')
|
||||
|
||||
if (params.extension === 'png') {
|
||||
const { data, info } = await pipeline.png().toBuffer({ resolveWithObject: true })
|
||||
return {
|
||||
buffer: data,
|
||||
extension: 'png',
|
||||
contentType: 'image/png',
|
||||
width: info.width ?? null,
|
||||
height: info.height ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
if (params.extension === 'webp') {
|
||||
const { data, info } = await pipeline.webp({ quality: 92 }).toBuffer({
|
||||
resolveWithObject: true,
|
||||
})
|
||||
return {
|
||||
buffer: data,
|
||||
extension: 'webp',
|
||||
contentType: 'image/webp',
|
||||
width: info.width ?? null,
|
||||
height: info.height ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
const { data, info } = await pipeline.jpeg({ quality: 92, mozjpeg: true }).toBuffer({
|
||||
resolveWithObject: true,
|
||||
})
|
||||
return {
|
||||
buffer: data,
|
||||
extension: 'jpg',
|
||||
contentType: 'image/jpeg',
|
||||
width: info.width ?? null,
|
||||
height: info.height ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadTemporaryWorkbenchReferenceImage(params: {
|
||||
buffer: Buffer
|
||||
contentType: string
|
||||
extension: string
|
||||
createdBy: string
|
||||
index?: number
|
||||
}): Promise<TemporaryWorkbenchStoredImage> {
|
||||
return uploadTemporaryWorkbenchImage({
|
||||
...params,
|
||||
kind: 'references',
|
||||
})
|
||||
}
|
||||
|
||||
export async function uploadTemporaryWorkbenchResultImage(params: {
|
||||
buffer: Buffer
|
||||
contentType: string
|
||||
extension: string
|
||||
createdBy: string
|
||||
index?: number
|
||||
}): Promise<TemporaryWorkbenchStoredImage> {
|
||||
return uploadTemporaryWorkbenchImage({
|
||||
...params,
|
||||
kind: 'results',
|
||||
})
|
||||
}
|
||||
|
||||
export async function persistTemporaryWorkbenchDetailReferenceImage(params: {
|
||||
buffer: Buffer
|
||||
contentType: string
|
||||
extension: string
|
||||
createdBy: string
|
||||
}): Promise<{
|
||||
url: string
|
||||
storageKey: string
|
||||
expiresAt: string
|
||||
temporary: true
|
||||
}> {
|
||||
const normalizedImage = await normalizeWorkbenchResultOutputSize({
|
||||
buffer: params.buffer,
|
||||
extension: params.extension,
|
||||
})
|
||||
const upload = await uploadTemporaryWorkbenchImage({
|
||||
buffer: normalizedImage.buffer,
|
||||
contentType: normalizedImage.contentType,
|
||||
extension: normalizedImage.extension,
|
||||
createdBy: params.createdBy,
|
||||
kind: 'detail-references',
|
||||
})
|
||||
|
||||
return {
|
||||
url: upload.url,
|
||||
storageKey: upload.storageKey,
|
||||
expiresAt: getTemporaryRenderExpiresAt(),
|
||||
temporary: true,
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,215 @@
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
|
||||
const DEFAULT_IMAGE_COUNT = 1
|
||||
const MAX_IMAGE_COUNT = 8
|
||||
const MAX_PROMPT_LENGTH = 5000
|
||||
const MAX_NEGATIVE_PROMPT_LENGTH = 1200
|
||||
export const MAX_STRUCTURE_REFERENCE_IMAGE_COUNT = 4
|
||||
export const DATA_URL_IMAGE_PATTERN = /^data:(image\/(?:png|jpeg|webp));base64,(.+)$/i
|
||||
const WORKBENCH_UPSTREAM_IMAGE_SIZE_CAP = '2K'
|
||||
|
||||
export interface DecodedReferenceImage {
|
||||
buffer: Buffer
|
||||
contentType: string
|
||||
extension: string
|
||||
}
|
||||
|
||||
export interface NormalizedMaterialPartAnnotation {
|
||||
nodeNames: string[]
|
||||
instruction: string
|
||||
}
|
||||
|
||||
export function trimToNull(value: string | null | undefined): string | null {
|
||||
const normalized = value?.trim()
|
||||
return normalized ? normalized : null
|
||||
}
|
||||
|
||||
export function toRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {}
|
||||
}
|
||||
|
||||
export function normalizeRequiredText(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string') {
|
||||
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, `缺少${label}`)
|
||||
}
|
||||
const normalized = value.trim()
|
||||
if (!normalized) {
|
||||
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, `缺少${label}`)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function normalizePrompt(value: unknown): string {
|
||||
if (typeof value !== 'string') {
|
||||
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请填写效果图提示词')
|
||||
}
|
||||
|
||||
const normalized = value.trim()
|
||||
if (!normalized) {
|
||||
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请填写效果图提示词')
|
||||
}
|
||||
if (normalized.length > MAX_PROMPT_LENGTH) {
|
||||
throw new AppError(ErrorCode.VALIDATION_OUT_OF_RANGE, `提示词不能超过 ${MAX_PROMPT_LENGTH} 个字符`)
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function normalizeNegativePrompt(value: unknown): string | null {
|
||||
if (value == null) return null
|
||||
if (typeof value !== 'string') {
|
||||
throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '反向提示词格式不正确')
|
||||
}
|
||||
|
||||
const normalized = trimToNull(value)
|
||||
if (!normalized) return null
|
||||
if (normalized.length > MAX_NEGATIVE_PROMPT_LENGTH) {
|
||||
throw new AppError(
|
||||
ErrorCode.VALIDATION_OUT_OF_RANGE,
|
||||
`反向提示词不能超过 ${MAX_NEGATIVE_PROMPT_LENGTH} 个字符`,
|
||||
)
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function normalizeImageCount(value: unknown): number {
|
||||
if (value == null || value === '') return DEFAULT_IMAGE_COUNT
|
||||
const parsed = typeof value === 'number' ? value : Number(value)
|
||||
if (!Number.isInteger(parsed) || parsed < 1 || parsed > MAX_IMAGE_COUNT) {
|
||||
throw new AppError(ErrorCode.VALIDATION_OUT_OF_RANGE, `生成张数必须在 1-${MAX_IMAGE_COUNT} 之间`)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
export function normalizeOptionalText(value: unknown, fieldLabel: string, maxLength: number): string | null {
|
||||
if (value == null || value === '') return null
|
||||
if (typeof value !== 'string') {
|
||||
throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, `${fieldLabel}格式不正确`)
|
||||
}
|
||||
|
||||
const normalized = value.trim()
|
||||
if (!normalized) return null
|
||||
if (normalized.length > maxLength) {
|
||||
throw new AppError(ErrorCode.VALIDATION_OUT_OF_RANGE, `${fieldLabel}不能超过 ${maxLength} 个字符`)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function normalizeOptionalViewIndex(value: unknown): number | null {
|
||||
if (value == null || value === '') return null
|
||||
const parsed = typeof value === 'number' ? value : Number(value)
|
||||
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 31) {
|
||||
throw new AppError(ErrorCode.VALIDATION_OUT_OF_RANGE, '视图序号必须在 0-31 之间')
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
export function normalizeShortOption(value: unknown, fallback: string, fieldLabel: string): string {
|
||||
if (value == null || value === '') return fallback
|
||||
if (typeof value !== 'string') {
|
||||
throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, `${fieldLabel}格式不正确`)
|
||||
}
|
||||
|
||||
const normalized = value.trim()
|
||||
if (!normalized) return fallback
|
||||
if (normalized.length > 32) {
|
||||
throw new AppError(ErrorCode.VALIDATION_OUT_OF_RANGE, `${fieldLabel}不能超过 32 个字符`)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function normalizeWorkbenchRequestImageSize(imageSize: string, isWorkbenchRender: boolean): string {
|
||||
if (!isWorkbenchRender) {
|
||||
return imageSize
|
||||
}
|
||||
|
||||
const normalized = imageSize.trim().toUpperCase()
|
||||
if (normalized === '4K') {
|
||||
return WORKBENCH_UPSTREAM_IMAGE_SIZE_CAP
|
||||
}
|
||||
|
||||
return normalized || WORKBENCH_UPSTREAM_IMAGE_SIZE_CAP
|
||||
}
|
||||
|
||||
export function decodeReferenceImageDataUrl(value: unknown): DecodedReferenceImage {
|
||||
if (typeof value !== 'string') {
|
||||
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '缺少当前 3D 预览截图')
|
||||
}
|
||||
|
||||
const match = value.match(DATA_URL_IMAGE_PATTERN)
|
||||
if (!match) {
|
||||
throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '3D 预览截图格式不正确')
|
||||
}
|
||||
|
||||
const contentType = match[1]!.toLowerCase()
|
||||
const base64 = match[2]!
|
||||
const buffer = Buffer.from(base64, 'base64')
|
||||
if (buffer.length === 0) {
|
||||
throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '3D 预览截图为空')
|
||||
}
|
||||
if (buffer.length > 12 * 1024 * 1024) {
|
||||
throw new AppError(ErrorCode.FILE_TOO_LARGE, '3D 预览截图不能超过 12MB')
|
||||
}
|
||||
|
||||
return {
|
||||
buffer,
|
||||
contentType,
|
||||
extension: contentType === 'image/png' ? 'png' : contentType === 'image/webp' ? 'webp' : 'jpg',
|
||||
}
|
||||
}
|
||||
|
||||
export function decodeReferenceImageDataUrls(value: unknown): DecodedReferenceImage[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
|
||||
return value
|
||||
.slice(0, MAX_STRUCTURE_REFERENCE_IMAGE_COUNT)
|
||||
.map((item) => decodeReferenceImageDataUrl(item))
|
||||
}
|
||||
|
||||
export function normalizeMaterialPartAnnotations(value: unknown): NormalizedMaterialPartAnnotation[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
|
||||
return value
|
||||
.slice(0, 12)
|
||||
.map((item) => {
|
||||
const record = toRecord(item)
|
||||
const nodeNames = Array.isArray(record.nodeNames)
|
||||
? Array.from(
|
||||
new Set(
|
||||
record.nodeNames
|
||||
.map((nodeName) => (typeof nodeName === 'string' ? nodeName.trim() : ''))
|
||||
.filter(Boolean),
|
||||
),
|
||||
).slice(0, 32)
|
||||
: []
|
||||
if (nodeNames.length === 0) {
|
||||
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '缺少模型标注部件')
|
||||
}
|
||||
const instruction = trimToNull(typeof record.instruction === 'string' ? record.instruction : null)
|
||||
if (!instruction) {
|
||||
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '缺少模型标注说明')
|
||||
}
|
||||
return {
|
||||
nodeNames,
|
||||
instruction,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function ensureJsonObject(value: unknown, message: string): Prisma.InputJsonValue {
|
||||
if (value == null) {
|
||||
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, message)
|
||||
}
|
||||
if (typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, `${message}格式不正确`)
|
||||
}
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue
|
||||
} catch {
|
||||
throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, `${message}必须是可保存的 JSON 对象`)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user