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.
186 lines
5.9 KiB
TypeScript
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', '生成失败,请稍后重试')
|
|
}
|
|
}
|