diff --git a/furniture-3d-system/src/server/services/assisted-assembly-ai-render.background.ts b/furniture-3d-system/src/server/services/assisted-assembly-ai-render.background.ts new file mode 100644 index 00000000..fb1c0650 --- /dev/null +++ b/furniture-3d-system/src/server/services/assisted-assembly-ai-render.background.ts @@ -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 { + 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', + } +} diff --git a/furniture-3d-system/src/server/services/assisted-assembly-ai-render.create.ts b/furniture-3d-system/src/server/services/assisted-assembly-ai-render.create.ts new file mode 100644 index 00000000..0a7bb115 --- /dev/null +++ b/furniture-3d-system/src/server/services/assisted-assembly-ai-render.create.ts @@ -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 { + 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 { + 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) + } +} diff --git a/furniture-3d-system/src/server/services/assisted-assembly-ai-render.materials.ts b/furniture-3d-system/src/server/services/assisted-assembly-ai-render.materials.ts new file mode 100644 index 00000000..eee7b1d3 --- /dev/null +++ b/furniture-3d-system/src/server/services/assisted-assembly-ai-render.materials.ts @@ -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): { + 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): 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): 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() + + 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')}` +} diff --git a/furniture-3d-system/src/server/services/assisted-assembly-ai-render.records.ts b/furniture-3d-system/src/server/services/assisted-assembly-ai-render.records.ts new file mode 100644 index 00000000..753ed448 --- /dev/null +++ b/furniture-3d-system/src/server/services/assisted-assembly-ai-render.records.ts @@ -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 + 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() + 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 + 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 { + 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 { + 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(), + } +} diff --git a/furniture-3d-system/src/server/services/assisted-assembly-ai-render.service.ts b/furniture-3d-system/src/server/services/assisted-assembly-ai-render.service.ts index 593e2c09..59f4ff1e 100644 --- a/furniture-3d-system/src/server/services/assisted-assembly-ai-render.service.ts +++ b/furniture-3d-system/src/server/services/assisted-assembly-ai-render.service.ts @@ -1,2042 +1,43 @@ -import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' -import path from 'node:path' -import { readdir, stat } from 'node:fs/promises' import type { Prisma } from '@prisma/client' -import { AssistedAssemblyAiRenderStatus } from '@prisma/client' -import { nanoid } from 'nanoid' -import sharp from 'sharp' import { prisma } from '@/lib/prisma' -import { - createObjectReadUrl, - deleteObjectWithResult, - type ObjectStorageDeleteResult, - readObjectStorageKeyFromUrl, - uploadBufferToObjectStorageWithMetadata, -} from '@/lib/storage' import type { AuthUser } from '@/server/core/types' import { AppError, ErrorCode } from '@/server/core/errors' import { aiRenderWorkbenchService, assertCanDeleteAiRenderWorkbenchImage, } from './ai-render-workbench.service' +import { decodeReferenceImageDataUrl, normalizeRequiredText, trimToNull } from './assisted-assembly-ai-render.validation' +import { createAssistedAssemblyAiRender } from './assisted-assembly-ai-render.create' +import type { CreateAssistedAssemblyAiRenderInput } from './assisted-assembly-ai-render.create' import { - callExternalImageGenerateApi, - getExternalImageApiConfig, -} from './external-image-generate.service' + deleteRenderImageStorageFile, + deleteRenderStorageFiles, + normalizeResultImages, + toAiRenderDTO, + type AssistedAssemblyAiRenderDTO, +} from './assisted-assembly-ai-render.records' +import { + cleanupExpiredLocalWorkbenchDetailReferenceFilesSafely, + deleteWorkbenchRenderStorageKey, + isSuccessfulWorkbenchRenderStorageDeleteResult, + isWorkbenchDetailReferenceStorageKey, + isWorkbenchTemporaryStorageKeyOwnedByUser, + logWorkbenchRenderStorageDeleteFailures, + persistTemporaryWorkbenchDetailReferenceImage, + WORKBENCH_RENDER_CLEANUP_BATCH_SIZE, + WORKBENCH_RENDER_CLEANUP_MIN_INTERVAL_MS, + WORKBENCH_RENDER_FILE_RETENTION_MS, + WORKBENCH_RENDER_STORAGE_ENCODED_PREFIX, + WORKBENCH_RENDER_STORAGE_PREFIX, +} from './assisted-assembly-ai-render.temp-storage' -const DEFAULT_IMAGE_COUNT = 1 -const MAX_IMAGE_COUNT = 8 -const MAX_PROMPT_LENGTH = 5000 -const MAX_NEGATIVE_PROMPT_LENGTH = 1200 -const MAX_STRUCTURE_REFERENCE_IMAGE_COUNT = 4 -const DATA_URL_IMAGE_PATTERN = /^data:(image\/(?:png|jpeg|webp));base64,(.+)$/i -const MAX_MATERIAL_TEXTURES = 6 -const WORKBENCH_RENDER_FILE_RETENTION_DAYS = 3 -const WORKBENCH_RENDER_FILE_RETENTION_MS = WORKBENCH_RENDER_FILE_RETENTION_DAYS * 24 * 60 * 60 * 1000 -const WORKBENCH_RENDER_STORAGE_PREFIX = 'ai-render-workbench/temp' -const WORKBENCH_RENDER_STORAGE_ENCODED_PREFIX = WORKBENCH_RENDER_STORAGE_PREFIX.replace(/\//g, '%2F') -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 WORKBENCH_UPSTREAM_IMAGE_SIZE_CAP = '2K' -const GENERATED_IMAGE_FETCH_TIMEOUT_MS = 60_000 -const WORKBENCH_RENDER_CLEANUP_BATCH_SIZE = 100 -const WORKBENCH_RENDER_CLEANUP_MIN_INTERVAL_MS = 5 * 60 * 1000 -const CLEAN_BACKGROUND_WORKBENCH_MODES = new Set(['white-background', 'eight-view']) +export type { + AssistedAssemblyAiRenderDTO, + AssistedAssemblyAiRenderImageDTO, +} from './assisted-assembly-ai-render.records' +export type { CreateAssistedAssemblyAiRenderInput } from './assisted-assembly-ai-render.create' let lastWorkbenchRenderCleanupAt = 0 -let lastWorkbenchDetailReferenceCleanupAt = 0 - -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 -} - -type MaterialReferenceDeliveryMode = 'signed-url' | 'file-upload' | 'mixed' | 'disabled' -type WorkbenchRenderStorageDeleteResult = ObjectStorageDeleteResult | { - provider: 'local' - key: string - status: 'deleted' | 'error' - message: string | null - statusCode: null -} - -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): { - 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 _inferMaterialFamily(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): 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): 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 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|\u6728|\u6a61\u6728|\u80e1\u6843\u6728|\u6986\u6728|\u6a31\u6843\u6728|\u677e\u6728|\u6989\u6728|\u767d\u8721\u6728)/.test(text)) { - return 'wood' - } - - if (/(stone|marble|granite|slate|travertine|terrazzo|concrete|\u77f3|\u5ca9|\u5927\u7406\u77f3|\u82b1\u5c97\u5ca9|\u7802\u5ca9)/.test(text)) { - return 'stone' - } - - if (/(leather|suede|\u76ae|\u76ae\u9769|\u4eff\u76ae)/.test(text)) { - return 'leather' - } - - if (/(fabric|linen|cotton|velvet|textile|woven|\u5e03|\u7ed2|\u68c9|\u9ebb|\u7ec7\u7269|\u7f51\u5e03)/.test(text)) { - return 'fabric' - } - - if (/(glass|acrylic|\u73bb\u7483|\u4e9a\u514b\u529b)/.test(text)) { - return 'glass' - } - - if (/(metal|steel|stainless|aluminum|brass|iron|\u94dc|\u94c1|\u94a2|\u94dd|\u91d1\u5c5e|\u4e0d\u9508\u94a2)/.test(text)) { - return 'metal' - } - - return 'solid' -} - -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(', ') -} - -function extractEnhancedMaterialEntriesFromSnapshot(assetSnapshot: unknown): MaterialTextureEntry[] { - const snapshot = toRecord(assetSnapshot) - const nodes = Array.isArray(snapshot.nodes) ? snapshot.nodes : [] - const materialEntries: MaterialTextureEntry[] = [] - const materialMap = new Map() - - 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) -} - -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') -} - -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 _extractMaterialTexturesFromSnapshot(assetSnapshot: unknown): MaterialTextureEntry[] { - const snapshot = toRecord(assetSnapshot) - const nodes = Array.isArray(snapshot.nodes) ? snapshot.nodes : [] - - // 按 materialConfig.id 聚合,记录每个材质被哪些节点使用 - const materialMap = new Map() - - for (const rawNode of nodes) { - const node = toRecord(rawNode) - const mc = toRecord(node.materialConfig) - const textureUrl = trimToNull(typeof mc.textureUrl === 'string' ? mc.textureUrl : null) - const nodeName = typeof node.name === 'string' ? node.name : null - - if (!textureUrl) continue - - const textureKey = readTextureStorageKey(textureUrl) - if (!textureKey) continue - - const dedupeKey = `storage:${textureKey}` - const materialId = trimToNull(typeof mc.id === 'string' ? mc.id : null) ?? dedupeKey - const materialName = trimToNull(typeof mc.name === 'string' ? mc.name : null) ?? materialId - - const existing = materialMap.get(dedupeKey) - if (existing) { - appendDistinctText(existing.nodeNames, nodeName) - } else { - materialMap.set(dedupeKey, { - materialId, - materialName, - textureKey, - nodeNames: nodeName ? [nodeName] : [], - }) - } - } - - return Array.from(materialMap.values()).slice(0, MAX_MATERIAL_TEXTURES) -} - -function _buildMaterialMappingPromptSuffix(entries: MaterialTextureEntry[]): string { - if (entries.length === 0) return '' - - const lines = entries.map((entry, index) => { - const partDesc = entry.nodeNames.length > 0 - ? entry.nodeNames.map(n => `"${n}"`).join(', ') - : `part #${index + 1}` - return `- [Reference #${index + 2}] ${entry.materialName} texture → apply to: ${partDesc}` - }) - - return `\n\nMaterial mapping for this product (each attached texture reference is labeled):\n${lines.join('\n')}\nApply each material texture to its corresponding part as shown in the reference image.` -} - -function _buildMaterialReferenceImageUrls(entries: MaterialTextureEntry[]): string[] { - const urls: string[] = [] - for (const entry of entries) { - if (!entry.textureKey) continue - try { - urls.push(createObjectReadUrl(entry.textureKey)) - } catch { - // Keep AI rendering usable if one material reference cannot be signed. - } - } - return urls -} - -export interface AssistedAssemblyAiRenderDTO { - id: string - workbenchSpuId: string | null - workbenchSkuId: string | null - prompt: string - negativePrompt: string | null - params: Record - 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 -} - -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 trimToNull(value: string | null | undefined): string | null { - const normalized = value?.trim() - return normalized ? normalized : null -} - -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 -} - -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 -} - -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 -} - -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 -} - -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}\u683c\u5f0f\u4e0d\u6b63\u786e`) - } - - const normalized = value.trim() - if (!normalized) return null - if (normalized.length > maxLength) { - throw new AppError(ErrorCode.VALIDATION_OUT_OF_RANGE, `${fieldLabel}\u4e0d\u80fd\u8d85\u8fc7 ${maxLength} \u4e2a\u5b57\u7b26`) - } - return normalized -} - -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, '\u89c6\u56fe\u5e8f\u53f7\u5fc5\u987b\u5728 0-31 \u4e4b\u95f4') - } - return parsed -} - -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 -} - -function normalizeWorkbenchRequestImageSize(imageSize: string, isWorkbenchRender: boolean): string { - if (!isWorkbenchRender) { - return imageSize - } - - const normalized = imageSize.trim().toUpperCase() - - // Workbench images are persisted locally at 1000px max, so retaining a hidden 4K - // request only increases latency and makes upstream 504s more likely. - if (normalized === '4K') { - return WORKBENCH_UPSTREAM_IMAGE_SIZE_CAP - } - - return normalized || WORKBENCH_UPSTREAM_IMAGE_SIZE_CAP -} - -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, - }, - } -} - -function decodeReferenceImageDataUrl(value: unknown): { buffer: Buffer; contentType: string; extension: string } { - 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', - } -} - -function decodeReferenceImageDataUrls(value: unknown): Array<{ buffer: Buffer; contentType: string; extension: string }> { - if (!Array.isArray(value)) return [] - - return value - .slice(0, MAX_STRUCTURE_REFERENCE_IMAGE_COUNT) - .map((item) => decodeReferenceImageDataUrl(item)) -} - -function normalizeMaterialPartAnnotations(value: unknown): Array<{ - nodeNames: string[] - instruction: string -}> { - 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, - } - }) -} - -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 - } -} - -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' -} - -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 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}/`) -} - -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 getTemporaryRenderExpiresAt(): string { - return new Date(Date.now() + WORKBENCH_RENDER_FILE_RETENTION_MS).toISOString() -} - -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/') -} - -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}/`) -} - -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<{ url: string; storageKey: string }> { - 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 cleanupExpiredLocalWorkbenchDetailReferenceFiles(): Promise { - 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 => { - 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) -} - -async function cleanupExpiredLocalWorkbenchDetailReferenceFilesSafely(): Promise { - try { - await cleanupExpiredLocalWorkbenchDetailReferenceFiles() - } catch (error) { - console.warn('[AiRenderWorkbench] cleanup expired detail reference files failed', error) - } -} - -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, - } -} - -function isSuccessfulWorkbenchRenderStorageDeleteResult( - result: WorkbenchRenderStorageDeleteResult, -): boolean { - return result.status === 'deleted' || result.status === 'missing' -} - -function logWorkbenchRenderStorageDeleteFailures( - context: string, - results: WorkbenchRenderStorageDeleteResult[], - details: Record, -): 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, - }) -} - -async function deleteWorkbenchRenderStorageKey( - storageKey: string, -): Promise { - 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) -} - -function normalizeTemporaryReferenceImages(value: unknown): Array<{ - url: string - storageKey: string | null - expiresAt: string | null - temporary: boolean -}> { - 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 - 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 { - url: string - storageKey: string | null - expiresAt: string | null - temporary: boolean - } => Boolean(item)) -} - -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) - } -} - -function shouldCleanWorkbenchResultBackground(mode: string | null | undefined): boolean { - return typeof mode === 'string' && CLEAN_BACKGROUND_WORKBENCH_MODES.has(mode) -} - -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)) -} - -async function cleanWorkbenchResultBackground(params: { - buffer: Buffer - extension: string - contentType: string -}): Promise<{ - buffer: Buffer - extension: string - contentType: string -}> { - 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', - } -} - -async function persistTemporaryWorkbenchResultImages(params: { - images: AssistedAssemblyAiRenderImageDTO[] - createdBy: string - mode?: string | null -}): Promise { - const expiresAt = getTemporaryRenderExpiresAt() - const storedImages: AssistedAssemblyAiRenderImageDTO[] = [] - const useLocalStorage = shouldUseLocalWorkbenchRenderStorage() - - 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(cleanedImageBuffer) - const storagePath = buildTemporaryRenderStorageKey({ - createdBy: params.createdBy, - kind: 'results', - extension: imageBuffer.extension, - index, - }) - const upload = useLocalStorage - ? await writeLocalWorkbenchRenderFile({ - buffer: imageBuffer.buffer, - storagePath, - }) - : await uploadBufferToObjectStorageWithMetadata( - imageBuffer.buffer, - storagePath, - imageBuffer.contentType, - ) - - 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 -} - -async function normalizeWorkbenchResultOutputSize(params: { - buffer: Buffer - extension: string - contentType: 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, - } -} - -async function uploadTemporaryWorkbenchReferenceImage(params: { - buffer: Buffer - contentType: string - extension: string - createdBy: string - index?: number -}): Promise<{ url: string; storageKey: string }> { - const storagePath = buildTemporaryRenderStorageKey({ - createdBy: params.createdBy, - kind: 'references', - extension: params.extension, - index: params.index, - }) - - if (shouldUseLocalWorkbenchRenderStorage()) { - return writeLocalWorkbenchRenderFile({ - buffer: params.buffer, - storagePath, - }) - } - - return uploadBufferToObjectStorageWithMetadata( - params.buffer, - storagePath, - params.contentType, - ) -} - -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, - contentType: params.contentType, - }) - const storagePath = buildTemporaryRenderStorageKey({ - createdBy: params.createdBy, - kind: 'detail-references', - extension: normalizedImage.extension, - }) - const upload = shouldUseLocalWorkbenchRenderStorage() - ? await writeLocalWorkbenchRenderFile({ - buffer: normalizedImage.buffer, - storagePath, - }) - : await uploadBufferToObjectStorageWithMetadata( - normalizedImage.buffer, - storagePath, - normalizedImage.contentType, - ) - - return { - url: upload.url, - storageKey: upload.storageKey, - expiresAt: getTemporaryRenderExpiresAt(), - temporary: true, - } -} - -function readRenderImageStorageKey(image: AssistedAssemblyAiRenderImageDTO): string | null { - return ( - trimToNull(image.storageKey) ?? - readImageStorageKey(image.originalUrl) ?? - readImageStorageKey(image.thumbnailUrl ?? null) - ) -} - -async function deleteRenderImageStorageFile( - image: AssistedAssemblyAiRenderImageDTO, -): Promise { - const storageKey = readRenderImageStorageKey(image) - if (storageKey) { - return [await deleteWorkbenchRenderStorageKey(storageKey)] - } - - return [] -} - -function collectRenderStorageKeys(record: { - referenceImageUrl: string | null - resultImages: Prisma.JsonValue - params?: Prisma.JsonValue | null -}) { - const keys = new Set() - 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) -} - -async function deleteRenderStorageFiles(record: { - referenceImageUrl: string | null - resultImages: Prisma.JsonValue - params?: Prisma.JsonValue | null -}, options?: { temporaryOnly?: boolean }) { - 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 -} async function cleanupExpiredWorkbenchRenderFiles(): Promise { const now = Date.now() @@ -2106,129 +107,10 @@ async function cleanupExpiredWorkbenchRenderFilesSafely(): Promise { } } -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 对象`) - } -} - -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 - 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 -} - -function toRecord(value: unknown): Record { - return value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : {} -} - -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(), - } -} - export class AssistedAssemblyAiRenderService { async listForUser(user: AuthUser, options?: { limit?: number }): Promise { await cleanupExpiredWorkbenchRenderFilesSafely() - const createdBy = normalizeRequiredText(user.id, '用户 ID') + const createdBy = normalizeRequiredText(user.id, '闁活潿鍔嶉崺?ID') const limit = Math.min(Math.max(options?.limit ?? 24, 1), 60) const records = await prisma.assistedAssemblyAiRender.findMany({ where: { createdBy }, @@ -2259,15 +141,15 @@ export class AssistedAssemblyAiRenderService { } async getForUser(id: string, user: AuthUser): Promise { - const normalizedId = normalizeRequiredText(id, 'AI 生图记录 ID') - const createdBy = normalizeRequiredText(user.id, '用户 ID') + const normalizedId = normalizeRequiredText(id, 'AI 闁汇垻鍠庡ù妯兼媼閺夎法绉?ID') + const createdBy = normalizeRequiredText(user.id, 'User ID') const record = await prisma.assistedAssemblyAiRender.findUnique({ where: { id: normalizedId }, include: { creator: { select: { name: true } } }, }) if (!record || (!record.workbenchSkuId && record.createdBy !== createdBy)) { - throw new AppError(ErrorCode.FILE_NOT_FOUND, '没有找到这条 AI 效果图记录') + throw new AppError(ErrorCode.FILE_NOT_FOUND, 'AI render record not found') } if (record.workbenchSkuId) { @@ -2278,8 +160,8 @@ export class AssistedAssemblyAiRenderService { } async deleteForUser(id: string, user: AuthUser, options?: { imageIndex?: number | null }): Promise { - const normalizedId = normalizeRequiredText(id, 'AI 生图记录 ID') - const createdBy = normalizeRequiredText(user.id, '用户 ID') + const normalizedId = normalizeRequiredText(id, 'AI render record ID') + const createdBy = normalizeRequiredText(user.id, 'User ID') const record = await prisma.assistedAssemblyAiRender.findUnique({ where: { id: normalizedId }, select: { @@ -2292,7 +174,7 @@ export class AssistedAssemblyAiRenderService { }, }) if (!record || (!record.workbenchSkuId && record.createdBy !== createdBy)) { - throw new AppError(ErrorCode.FILE_NOT_FOUND, '没有找到这条 AI 效果图记录') + throw new AppError(ErrorCode.FILE_NOT_FOUND, 'AI render record not found') } if (record.workbenchSkuId) { @@ -2338,7 +220,7 @@ export class AssistedAssemblyAiRenderService { expiresAt: string temporary: true }> { - const createdBy = normalizeRequiredText(user.id, '用户 ID') + const createdBy = normalizeRequiredText(user.id, 'User ID') await cleanupExpiredWorkbenchRenderFilesSafely() await cleanupExpiredLocalWorkbenchDetailReferenceFilesSafely() await aiRenderWorkbenchService.getSkuForRender(input.workbenchSkuId, user) @@ -2359,13 +241,13 @@ export class AssistedAssemblyAiRenderService { }, user: AuthUser, ): Promise { - const createdBy = normalizeRequiredText(user.id, '用户 ID') - const storageKey = normalizeRequiredText(input.storageKey, '特写参考图存储键') + const createdBy = normalizeRequiredText(user.id, 'User ID') + const storageKey = normalizeRequiredText(input.storageKey, 'Detail reference storage key') if (!isWorkbenchDetailReferenceStorageKey(storageKey)) { - throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '特写参考图存储键不正确') + throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, 'Detail reference storage key is invalid') } if (!isWorkbenchTemporaryStorageKeyOwnedByUser(storageKey, createdBy)) { - throw new AppError(ErrorCode.FILE_NOT_FOUND, '没有找到这张特写参考图') + throw new AppError(ErrorCode.FILE_NOT_FOUND, 'Detail reference image not found') } if (trimToNull(input.workbenchSkuId)) { await aiRenderWorkbenchService.getSkuForRender(input.workbenchSkuId!, user) @@ -2375,238 +257,8 @@ export class AssistedAssemblyAiRenderService { } async create(input: CreateAssistedAssemblyAiRenderInput, user: AuthUser): Promise { - const startedAt = Date.now() - const createdBy = normalizeRequiredText(user.id, '用户 ID') await cleanupExpiredWorkbenchRenderFilesSafely() - 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, '\u56fe\u96c6\u6279\u6b21', 160) - const viewName = normalizeOptionalText(input.viewName, '\u89c6\u56fe\u540d\u79f0', 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 - - 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 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 baseCreateData = { - prompt, - negativePrompt, - params: buildCurrentParams(), - referenceImageUrl: referenceUpload.url, - 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, - } - - const config = await getExternalImageApiConfig() - if (!config.enabled || !config.baseUrl || !config.apiKey) { - const record = await prisma.assistedAssemblyAiRender.create({ - data: { - ...baseCreateData, - 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: { - ...baseCreateData, - 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: { - prompt, - negativePrompt, - params: 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 } - : {}), - }), - referenceImageUrl: referenceUpload.url, - 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.FAILED, - errorMessage: error instanceof Error ? error.message : 'AI 生图失败', - durationMs: Date.now() - startedAt, - }, - }) - return toAiRenderDTO(record, user.name) - } + return createAssistedAssemblyAiRender(input, user) } } diff --git a/furniture-3d-system/src/server/services/assisted-assembly-ai-render.temp-storage.ts b/furniture-3d-system/src/server/services/assisted-assembly-ai-render.temp-storage.ts new file mode 100644 index 00000000..e895cdde --- /dev/null +++ b/furniture-3d-system/src/server/services/assisted-assembly-ai-render.temp-storage.ts @@ -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 { + 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 { + 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 { + 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 => { + 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 { + 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, +): 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 { + 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 + 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 { + return uploadTemporaryWorkbenchImage({ + ...params, + kind: 'references', + }) +} + +export async function uploadTemporaryWorkbenchResultImage(params: { + buffer: Buffer + contentType: string + extension: string + createdBy: string + index?: number +}): Promise { + 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, + } +} diff --git a/furniture-3d-system/src/server/services/assisted-assembly-ai-render.validation.ts b/furniture-3d-system/src/server/services/assisted-assembly-ai-render.validation.ts new file mode 100644 index 00000000..047036e9 --- /dev/null +++ b/furniture-3d-system/src/server/services/assisted-assembly-ai-render.validation.ts @@ -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 { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {} +} + +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 对象`) + } +}