fix(ai-render): add manual white-background detail references

This commit is contained in:
WuYongJie 2026-06-23 09:52:01 +08:00
parent 6d721d7b9a
commit ff4f2bc962
5 changed files with 807 additions and 283 deletions

View File

@ -75,6 +75,13 @@ export interface AiRenderCameraViewSnapshot {
target: [number, number, number]
}
export interface AiRenderTemporaryReferenceImage {
url: string
storageKey: string
expiresAt?: string | null
temporary: boolean
}
export interface LocalRenderRecord {
id: string
serverRenderId?: string

View File

@ -0,0 +1,73 @@
import { NextRequest } from 'next/server'
import { z } from 'zod'
import { AppError, ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { assistedAssemblyAiRenderService } from '@/server/services'
const createDetailReferenceSchema = z.object({
workbenchSkuId: z.string().trim().min(1, '缺少 SKU ID'),
referenceImageDataUrl: z.string().min(1, '缺少特写参考图'),
})
const deleteDetailReferenceSchema = z.object({
storageKey: z.string().trim().min(1, '缺少特写参考图存储键'),
workbenchSkuId: z.string().trim().optional().nullable(),
})
export async function POST(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
const body = await request.json().catch(() => null)
const parsed = createDetailReferenceSchema.safeParse(body)
if (!parsed.success) {
return jsonError(
ErrorCode.VALIDATION_FAILED,
parsed.error.issues[0]?.message ?? '特写参考图参数不正确',
400,
ctx.requestId,
)
}
try {
const image = await assistedAssemblyAiRenderService.createWorkbenchDetailReference(parsed.data, ctx.user)
return jsonSuccess({ image }, 201, ctx.requestId)
} catch (error) {
if (error instanceof AppError) {
return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
}
throw error
}
},
{ requireAuth: true },
)
}
export async function DELETE(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
const body = await request.json().catch(() => null)
const parsed = deleteDetailReferenceSchema.safeParse(body)
if (!parsed.success) {
return jsonError(
ErrorCode.VALIDATION_FAILED,
parsed.error.issues[0]?.message ?? '特写参考图删除参数不正确',
400,
ctx.requestId,
)
}
try {
await assistedAssemblyAiRenderService.deleteWorkbenchDetailReference(parsed.data, ctx.user)
return jsonSuccess({ ok: true }, 200, ctx.requestId)
} catch (error) {
if (error instanceof AppError) {
return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
}
throw error
}
},
{ requireAuth: true },
)
}

View File

@ -942,6 +942,13 @@ export interface CreateAssistedAssemblyAiRenderPayload {
assetSnapshot: unknown
}
export interface AiRenderWorkbenchTemporaryImageDTO {
url: string
storageKey: string
expiresAt: string | null
temporary: boolean
}
export async function listAssistedAssemblyAiRenders(limit = 24): Promise<AssistedAssemblyAiRenderDTO[]> {
const searchParams = new URLSearchParams({ limit: String(limit) })
const data = await requestPlatform<{ renders: AssistedAssemblyAiRenderDTO[] }>(
@ -954,6 +961,30 @@ export async function fetchAiRenderWorkbench(): Promise<AiRenderWorkbenchDTO> {
return requestPlatform<AiRenderWorkbenchDTO>('/api/ai-render-workbench')
}
export async function createAiRenderWorkbenchDetailReference(input: {
workbenchSkuId: string
referenceImageDataUrl: string
}): Promise<AiRenderWorkbenchTemporaryImageDTO> {
const data = await requestPlatform<{ image: AiRenderWorkbenchTemporaryImageDTO }>(
'/api/ai-render-workbench/detail-reference',
{
method: 'POST',
body: JSON.stringify(input),
},
)
return data.image
}
export async function deleteAiRenderWorkbenchDetailReference(input: {
storageKey: string
workbenchSkuId?: string | null
}): Promise<void> {
await requestPlatform<{ ok: boolean }>('/api/ai-render-workbench/detail-reference', {
method: 'DELETE',
body: JSON.stringify(input),
})
}
export async function createAiRenderWorkbenchSpu(input?: {
name?: string | null
description?: string | null

View File

@ -1,5 +1,6 @@
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'
@ -44,6 +45,7 @@ const WORKBENCH_RENDER_CLEANUP_MIN_INTERVAL_MS = 5 * 60 * 1000
const CLEAN_BACKGROUND_WORKBENCH_MODES = new Set(['white-background', 'eight-view'])
let lastWorkbenchRenderCleanupAt = 0
let lastWorkbenchDetailReferenceCleanupAt = 0
interface MaterialTextureEntry {
materialId: string
@ -1010,7 +1012,7 @@ function getImageExtensionFromUrl(value: string): string | null {
function buildTemporaryRenderStorageKey(params: {
createdBy: string
kind: 'references' | 'results'
kind: 'references' | 'results' | 'detail-references'
extension: string
index?: number
}): string {
@ -1022,6 +1024,19 @@ 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)) {
@ -1055,6 +1070,59 @@ async function writeLocalWorkbenchRenderFile(params: {
}
}
async function cleanupExpiredLocalWorkbenchDetailReferenceFiles(): Promise<void> {
if (!shouldUseLocalWorkbenchRenderStorage()) {
return
}
const now = Date.now()
if (now - lastWorkbenchDetailReferenceCleanupAt < WORKBENCH_RENDER_CLEANUP_MIN_INTERVAL_MS) {
return
}
lastWorkbenchDetailReferenceCleanupAt = now
const rootDirectory = path.resolve(LOCAL_UPLOADS_ROOT, WORKBENCH_RENDER_STORAGE_PREFIX)
const cutoffTime = now - WORKBENCH_RENDER_FILE_RETENTION_MS
const walk = async (directory: string): Promise<void> => {
const entries = await readdir(directory, { withFileTypes: true }).catch((error: NodeJS.ErrnoException) => {
if (error?.code === 'ENOENT') {
return []
}
throw error
})
for (const entry of entries) {
const absolutePath = path.join(directory, entry.name)
if (entry.isDirectory()) {
await walk(absolutePath)
continue
}
if (!absolutePath.includes(`${path.sep}detail-references${path.sep}`)) {
continue
}
const fileStat = await stat(absolutePath).catch(() => null)
if (!fileStat || fileStat.mtimeMs >= cutoffTime) {
continue
}
await rm(absolutePath, { force: true }).catch(() => undefined)
}
}
await walk(rootDirectory)
}
async function cleanupExpiredLocalWorkbenchDetailReferenceFilesSafely(): Promise<void> {
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
@ -1761,6 +1829,46 @@ async function uploadTemporaryWorkbenchReferenceImage(params: {
)
}
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) ??
@ -2094,6 +2202,54 @@ export class AssistedAssemblyAiRenderService {
await prisma.assistedAssemblyAiRender.delete({ where: { id: record.id } })
}
async createWorkbenchDetailReference(
input: {
workbenchSkuId: string
referenceImageDataUrl: string
},
user: AuthUser,
): Promise<{
url: string
storageKey: string
expiresAt: string
temporary: true
}> {
const createdBy = normalizeRequiredText(user.id, '用户 ID')
await cleanupExpiredWorkbenchRenderFilesSafely()
await cleanupExpiredLocalWorkbenchDetailReferenceFilesSafely()
await aiRenderWorkbenchService.getSkuForRender(input.workbenchSkuId, user)
const referenceImage = decodeReferenceImageDataUrl(input.referenceImageDataUrl)
return persistTemporaryWorkbenchDetailReferenceImage({
buffer: referenceImage.buffer,
contentType: referenceImage.contentType,
extension: referenceImage.extension,
createdBy,
})
}
async deleteWorkbenchDetailReference(
input: {
storageKey: string
workbenchSkuId?: string | null
},
user: AuthUser,
): Promise<void> {
const createdBy = normalizeRequiredText(user.id, '用户 ID')
const storageKey = normalizeRequiredText(input.storageKey, '特写参考图存储键')
if (!isWorkbenchDetailReferenceStorageKey(storageKey)) {
throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '特写参考图存储键不正确')
}
if (!isWorkbenchTemporaryStorageKeyOwnedByUser(storageKey, createdBy)) {
throw new AppError(ErrorCode.FILE_NOT_FOUND, '没有找到这张特写参考图')
}
if (trimToNull(input.workbenchSkuId)) {
await aiRenderWorkbenchService.getSkuForRender(input.workbenchSkuId!, user)
}
await deleteWorkbenchRenderStorageKey(storageKey)
}
async create(input: CreateAssistedAssemblyAiRenderInput, user: AuthUser): Promise<AssistedAssemblyAiRenderDTO> {
const startedAt = Date.now()
const createdBy = normalizeRequiredText(user.id, '用户 ID')