fix(ai-render): add manual white-background detail references
This commit is contained in:
parent
6d721d7b9a
commit
ff4f2bc962
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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')
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user