WuYongJie 928a7b37a9 feat(ai): use durable Relay native image tasks
Persist image submissions with fixed idempotency keys, leased execution, provider
identity, reference snapshots and staged results. Integrate DIY, assisted assembly
and non-integrated white-background sessions without holding generation in a
browser request. Recover ambiguous submissions through owner-scoped reads and
stop a new eight-view batch when restoring an earlier request.

Add the NativeImageGenerationJob migration and a dedicated worker to both release
entry points. Protect job inputs/results from storage GC. Existing production
configuration and business data are not changed by this commit.

Validation: 256 focused Jest tests, isolated PostgreSQL migration/lease/transaction
checks (8), deployment guard tests (9), type-check, production build and desktop/
mobile browser component scenarios. Live native 2K reference-image request worked;
reusing its idempotency key returned the same task. Build uses local placeholders
for otherwise absent Drawing System configuration. Three unrelated material tests
also fail with the original mainline test file; they remain outside this change.

Architecture/visual/root-cause review: passed with warnings. Independent review
completed for the integration and shared input/storage paths; worker additionally
reviewed by the primary agent. Production merge/deployment requires authorization.
2026-10-15 14:55:46 +08:00

186 lines
5.9 KiB
TypeScript

'use client'
import type { CameraController } from '@/lib/3d/camera-controller'
import {
createAssistedAssemblyAiRender,
waitForAssistedAssemblyAiRender,
type CreateAssistedAssemblyAiRenderPayload,
} from '@/lib/open-product/platform-api'
import {
buildLockedScenePrompt,
CURVE_DETAIL_ENHANCEMENT_NEGATIVE_PROMPT,
WHITE_BACKGROUND_COMPONENT_TYPE_LOCK_NEGATIVE_PROMPT,
WHITE_BACKGROUND_MATERIAL_SCOPE_LOCK_NEGATIVE_PROMPT,
} from './promptBuilders'
import { mergeNegativePrompt, buildTaskStartedMessage } from './workbenchHelpers'
import type {
AiRenderCameraViewSnapshot,
AiRenderSku,
AiRenderSpu,
AiRenderTaskState,
LocalRenderRecord,
} from './types'
import { buildAiRenderAssetSnapshot, buildAiRenderWorkspaceSnapshot } from './utils'
type SceneGenerationParams = {
canGenerate: boolean
generating: boolean
selectedSku: AiRenderSku | null
selectedSpu: AiRenderSpu | null
prompt: string
negativePrompt: string
aspectRatio: string
imageCount: number
imageSize: CreateAssistedAssemblyAiRenderPayload['imageSize']
modelNodeNames: string[]
getCurrentCameraView: () => AiRenderCameraViewSnapshot | null
getController: () => CameraController | null
captureCurrentReferenceImage: () => string | null
captureReferenceAtView: (
view: AiRenderCameraViewSnapshot,
options?: {
controller?: CameraController | null
restoreAfterCapture?: boolean
},
) => Promise<string | null>
startRenderTask: (params: {
mode: LocalRenderRecord['mode']
skuId: string
skuName: string
imageCount: number
}) => AiRenderTaskState | null
appendServerRenderRecords: (render: any, skuFallback?: AiRenderSku | null) => number
setMessageForSku: (skuId: string, nextMessage: string | null) => void
settleRenderTask: (taskId: string, nextStatus: 'succeeded' | 'failed', stage: string) => void
setMessage: (message: string | null) => void
}
export async function runSceneGeneration(params: SceneGenerationParams): Promise<void> {
const {
canGenerate,
generating,
selectedSku,
selectedSpu,
prompt,
negativePrompt,
aspectRatio,
imageCount,
imageSize,
modelNodeNames,
getCurrentCameraView,
getController,
captureCurrentReferenceImage,
captureReferenceAtView,
startRenderTask,
appendServerRenderRecords,
setMessageForSku,
settleRenderTask,
setMessage,
} = params
if (!canGenerate || generating) return
const taskSku = selectedSku
const taskSpu = selectedSpu
const controller = getController()
if (!taskSku || !taskSku.modelUrl) {
setMessage('请先选择并配置一个 SKU 模型文件。')
return
}
const cameraView = getCurrentCameraView()
const referenceImageDataUrl =
cameraView && controller
? await captureReferenceAtView(cameraView, {
controller,
restoreAfterCapture: true,
})
: captureCurrentReferenceImage()
if (!referenceImageDataUrl) {
setMessage('当前 3D 预览还没准备好,无法作为 AI 参考图。')
return
}
const normalizedPrompt = prompt.trim()
if (!normalizedPrompt) {
setMessage('请先填写场景效果图提示词。')
return
}
const taskImageCount = imageCount
const taskAspectRatio = aspectRatio
const taskImageSize = imageSize
const taskNegativePrompt = negativePrompt
const taskNodeNames = [...modelNodeNames]
const lockedScenePrompt = buildLockedScenePrompt({
spu: taskSpu,
sku: taskSku,
userPrompt: normalizedPrompt,
aspectRatio: taskAspectRatio,
})
const task = startRenderTask({
mode: 'scene',
skuId: taskSku.id,
skuName: taskSku.name || '未命名 SKU',
imageCount: taskImageCount,
})
if (!task) {
setMessage('当前 SKU 已有生图任务在后台运行。')
return
}
setMessage(buildTaskStartedMessage(task))
try {
const pending = await createAssistedAssemblyAiRender({
prompt: lockedScenePrompt,
negativePrompt: mergeNegativePrompt(
taskNegativePrompt,
WHITE_BACKGROUND_COMPONENT_TYPE_LOCK_NEGATIVE_PROMPT,
WHITE_BACKGROUND_MATERIAL_SCOPE_LOCK_NEGATIVE_PROMPT,
CURVE_DETAIL_ENHANCEMENT_NEGATIVE_PROMPT,
),
imageCount: taskImageCount,
aspectRatio: taskAspectRatio,
imageSize: taskImageSize,
mode: 'scene',
intakeSkuItemId: taskSku.id,
referenceImageDataUrl,
workspaceSnapshot: buildAiRenderWorkspaceSnapshot({
spu: taskSpu,
sku: taskSku,
prompt: lockedScenePrompt,
mode: 'scene',
cameraView,
}),
assetSnapshot: buildAiRenderAssetSnapshot({
spu: taskSpu,
sku: taskSku,
nodeNames: taskNodeNames,
}),
}, { onUncertain: (message) => setMessageForSku(taskSku.id, message) })
appendServerRenderRecords(pending, taskSku)
if (pending.submissionRecovery) {
setMessageForSku(taskSku.id, '已找回上次提交的任务,请先核对原记录。本次没有新建生成任务。')
settleRenderTask(task.id, 'succeeded', '已恢复原任务,未新建任务')
return
}
const render = await waitForAssistedAssemblyAiRender(pending)
const savedCount = appendServerRenderRecords(render, taskSku)
if (render.status === 'SUCCEEDED' && savedCount > 0) {
setMessageForSku(
taskSku.id,
render.errorMessage || (savedCount > 1 ? `场景效果图已生成,共 ${savedCount} 张。` : '场景效果图已生成。'),
)
settleRenderTask(task.id, 'succeeded', `已完成,生成 ${savedCount} 张图片`)
return
}
setMessageForSku(taskSku.id, render.errorMessage || 'AI 生图请求已返回,但没有可用结果图。')
settleRenderTask(task.id, 'failed', render.status === 'PENDING' ? '任务待核对,请勿重复提交' : '生成结束,但没有返回可用图片')
} catch (error) {
setMessageForSku(taskSku.id, error instanceof Error ? error.message : 'AI 生图失败')
settleRenderTask(task.id, 'failed', '生成失败,请稍后重试')
}
}