refactor(ai-render-workbench): split ai render backend flow

This commit is contained in:
WuYongJie 2026-07-01 15:37:39 +08:00
parent 135cab0990
commit d02f27b28f
7 changed files with 2411 additions and 2389 deletions

View File

@ -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',
}
}

View File

@ -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)
}
}

View File

@ -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')}`
}

View File

@ -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(),
}
}

View File

@ -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,
}
}

View File

@ -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 对象`)
}
}