diff --git a/furniture-3d-system/src/app/ai-render-workbench/AiRenderWorkbenchClient.tsx b/furniture-3d-system/src/app/ai-render-workbench/AiRenderWorkbenchClient.tsx index c8843211..af210770 100644 --- a/furniture-3d-system/src/app/ai-render-workbench/AiRenderWorkbenchClient.tsx +++ b/furniture-3d-system/src/app/ai-render-workbench/AiRenderWorkbenchClient.tsx @@ -24,6 +24,7 @@ import { UvSettingsSubmenu } from '@/app/asset-center/PreviewSubmenus' import { RenderInspector } from './RenderInspector' import type { AiRenderNodeMaterial, + AiRenderTaskState, AiRenderSku, AiRenderSpu, AiRenderUvEditorFormState, @@ -56,6 +57,16 @@ const CURVE_DETAIL_ENHANCEMENT_PROMPT = const CURVE_DETAIL_ENHANCEMENT_NEGATIVE_PROMPT = 'changed product structure, changed dimensions, extra holes, missing holes, missing slots, distorted straight edges, melted corners, warped panels, altered material grain direction, changed part boundaries, blurry product' +const AI_RENDER_TASK_PROGRESS_CAP = 88 +const AI_RENDER_TASK_PROGRESS_STAGES = [ + { progress: 12, stage: '正在抓取当前 3D 参考图' }, + { progress: 24, stage: '正在提交 AI 生图请求' }, + { progress: 42, stage: 'AI 正在锁定产品结构' }, + { progress: 58, stage: '正在生成图像细节' }, + { progress: 72, stage: '正在增强曲面和材质表现' }, + { progress: 84, stage: '等待 AI 返回结果' }, +] as const + function mergeNegativePrompt(...parts: Array): string | null { const normalized = parts .map((part) => part?.trim()) @@ -64,6 +75,19 @@ function mergeNegativePrompt(...parts: Array): string return normalized.length > 0 ? normalized.join(', ') : null } +function getTaskModeLabel(mode: LocalRenderRecord['mode']): string { + return mode === 'white-background' ? '白底图' : '场景效果图' +} + +function buildTaskStartedMessage(params: { + mode: LocalRenderRecord['mode'] + skuName: string + imageCount: number +}) { + const modeLabel = getTaskModeLabel(params.mode) + return `${modeLabel}已进入后台生成:${params.skuName},共 ${params.imageCount} 张。你可以继续切换 SKU 或编辑参数。` +} + type StoredWorkbenchState = { spus: AiRenderSpu[] selectedSpuId: string | null @@ -262,6 +286,7 @@ function normalizeRecords(value: unknown): LocalRenderRecord[] { return { id: typeof record.id === 'string' && record.id.trim() ? record.id : createId('render'), + skuId: typeof record.skuId === 'string' ? record.skuId : '', skuName: typeof record.skuName === 'string' ? record.skuName : '未命名 SKU', mode, imageUrl: typeof record.imageUrl === 'string' ? record.imageUrl : '', @@ -271,7 +296,6 @@ function normalizeRecords(value: unknown): LocalRenderRecord[] { } }) .filter((record) => record.imageUrl.trim()) - .slice(0, 18) } function readStoredWorkbenchState(): StoredWorkbenchState | null { @@ -503,7 +527,7 @@ export default function AiRenderWorkbenchClient({ finishes }: AiRenderWorkbenchC const [imageCount, setImageCount] = useState(storedWorkbenchState?.imageCount ?? 1) const [records, setRecords] = useState(() => storedWorkbenchState?.records ?? []) const [message, setMessage] = useState(null) - const [generating, setGenerating] = useState(false) + const [renderTask, setRenderTask] = useState(null) const [optimizing, setOptimizing] = useState(false) const [optimExplanation, setOptimExplanation] = useState('') const [uploadingSkuId, setUploadingSkuId] = useState(null) @@ -531,6 +555,11 @@ export default function AiRenderWorkbenchClient({ finishes }: AiRenderWorkbenchC () => buildSkuMaterialAssignments(selectedSku, modelNodeNames), [modelNodeNames, selectedSku], ) + const selectedSkuRecords = useMemo(() => { + if (!selectedSku) return [] + return records.filter((record) => record.skuId === selectedSku.id) + }, [records, selectedSku]) + const generating = renderTask?.status === 'running' const materialPanelTargetNodeNames = useMemo(() => { if (!materialPanel) return [] const targetNodeNames = materialPanel.scope === 'all' @@ -619,6 +648,39 @@ export default function AiRenderWorkbenchClient({ finishes }: AiRenderWorkbenchC setSpus((current) => (isLegacySampleOnly(current) ? [] : current)) }, []) + useEffect(() => { + if (!renderTask || renderTask.status !== 'running') return + + const intervalId = window.setInterval(() => { + setRenderTask((current) => { + if (!current || current.id !== renderTask.id || current.status !== 'running') return current + + const elapsedMs = Date.now() - new Date(current.startedAt).getTime() + const stage = [...AI_RENDER_TASK_PROGRESS_STAGES] + .reverse() + .find((item) => elapsedMs >= item.progress * 320) + ?? AI_RENDER_TASK_PROGRESS_STAGES[0] + const drift = Math.floor(elapsedMs / 1800) + const nextProgress = Math.min( + AI_RENDER_TASK_PROGRESS_CAP, + Math.max(current.progress, stage.progress + drift), + ) + + if (nextProgress === current.progress && stage.stage === current.stage) { + return current + } + + return { + ...current, + progress: nextProgress, + stage: stage.stage, + } + }) + }, 700) + + return () => window.clearInterval(intervalId) + }, [renderTask]) + useEffect(() => { if (typeof window === 'undefined') return @@ -1041,10 +1103,13 @@ export default function AiRenderWorkbenchClient({ finishes }: AiRenderWorkbenchC const appendRenderedRecords = useCallback((params: { mode: LocalRenderRecord['mode'] images: Array<{ originalUrl?: string | null; thumbnailUrl?: string | null }> + skuId: string + skuName?: string prompt?: string status?: LocalRenderRecord['status'] }) => { - if (!selectedSku) return 0 + const skuId = params.skuId + const skuName = params.skuName ?? selectedSku?.name ?? '未命名 SKU' const createdAt = new Date().toISOString() const nextRecords = params.images .map((image) => { @@ -1057,7 +1122,8 @@ export default function AiRenderWorkbenchClient({ finishes }: AiRenderWorkbenchC if (!imageUrl) return null return { id: createId('render'), - skuName: selectedSku.name || '未命名 SKU', + skuId, + skuName, mode: params.mode, imageUrl, prompt: params.prompt ?? prompt, @@ -1069,13 +1135,56 @@ export default function AiRenderWorkbenchClient({ finishes }: AiRenderWorkbenchC if (nextRecords.length === 0) return 0 - setRecords((current) => [...nextRecords, ...current].slice(0, 18)) + setRecords((current) => { + const sameSkuRecords = [...nextRecords, ...current.filter((record) => record.skuId === skuId)].slice(0, 18) + const otherSkuRecords = current.filter((record) => record.skuId !== skuId) + return [...sameSkuRecords, ...otherSkuRecords] + }) return nextRecords.length }, [prompt, selectedSku]) + const startRenderTask = useCallback((params: { + mode: LocalRenderRecord['mode'] + skuId: string + skuName: string + imageCount: number + }) => { + const task: AiRenderTaskState = { + id: createId('render-task'), + skuId: params.skuId, + skuName: params.skuName, + mode: params.mode, + imageCount: params.imageCount, + progress: 12, + stage: AI_RENDER_TASK_PROGRESS_STAGES[0].stage, + status: 'running', + startedAt: new Date().toISOString(), + } + setRenderTask(task) + return task + }, []) + + const settleRenderTask = useCallback((taskId: string, nextStatus: 'succeeded' | 'failed', stage: string) => { + setRenderTask((current) => + current?.id === taskId + ? { + ...current, + status: nextStatus, + progress: 100, + stage, + } + : current, + ) + + window.setTimeout(() => { + setRenderTask((current) => (current?.id === taskId ? null : current)) + }, 1400) + }, []) const handleCaptureWhiteBackground = useCallback(async () => { if (generating) return - if (!selectedSku || !selectedSku.modelUrl) { + const taskSku = selectedSku + const taskSpu = selectedSpu + if (!taskSku || !taskSku.modelUrl) { setMessage('请先选择并配置一个 SKU 模型文件。') return } @@ -1089,53 +1198,65 @@ export default function AiRenderWorkbenchClient({ finishes }: AiRenderWorkbenchC setMessage('当前 3D 预览还没准备好,稍等模型加载完成再试。') return } + const taskImageCount = imageCount + const taskAspectRatio = aspectRatio + const taskImageSize = imageSize + const taskNegativePrompt = negativePrompt + const taskNodeNames = [...modelNodeNames] const lockedPrompt = buildWhiteBackgroundPrompt({ - spu: selectedSpu, - sku: selectedSku, + spu: taskSpu, + sku: taskSku, userPrompt: prompt, - aspectRatio, + aspectRatio: taskAspectRatio, }) - - setGenerating(true) - setMessage('正在基于当前 3D 参考图生成白底图,并增强低分段圆弧细节...') + const task = startRenderTask({ + mode: 'white-background', + skuId: taskSku.id, + skuName: taskSku.name || '未命名 SKU', + imageCount: taskImageCount, + }) + setMessage(buildTaskStartedMessage(task)) try { const render = await createAssistedAssemblyAiRender({ prompt: lockedPrompt, - negativePrompt: mergeNegativePrompt(negativePrompt, CURVE_DETAIL_ENHANCEMENT_NEGATIVE_PROMPT), - imageCount, - aspectRatio, - imageSize, + negativePrompt: mergeNegativePrompt(taskNegativePrompt, CURVE_DETAIL_ENHANCEMENT_NEGATIVE_PROMPT), + imageCount: taskImageCount, + aspectRatio: taskAspectRatio, + imageSize: taskImageSize, referenceImageDataUrl: dataUrl, workspaceSnapshot: buildAiRenderWorkspaceSnapshot({ - spu: selectedSpu, - sku: selectedSku, + spu: taskSpu, + sku: taskSku, prompt: lockedPrompt, mode: 'white-background', }), assetSnapshot: buildAiRenderAssetSnapshot({ - spu: selectedSpu, - sku: selectedSku, - nodeNames: modelNodeNames, + spu: taskSpu, + sku: taskSku, + nodeNames: taskNodeNames, }), }) const savedCount = appendRenderedRecords({ mode: 'white-background', images: render.resultImages, + skuId: task.skuId, + skuName: task.skuName, prompt: lockedPrompt, }) if (render.status === 'SUCCEEDED' && savedCount > 0) { setMessage( savedCount > 1 - ? `精准白底图已生成,共 ${savedCount} 张,并按当前模型参考增强了低分段圆弧的视觉细节。` - : '精准白底图已生成,并按当前模型参考增强了低分段圆弧的视觉细节。', + ? `白底图已生成,共 ${savedCount} 张,并按当前模型参考增强了低分段圆弧的视觉细节。` + : '白底图已生成,并按当前模型参考增强了低分段圆弧的视觉细节。', ) + settleRenderTask(task.id, 'succeeded', `已完成,生成 ${savedCount} 张图片`) } else { setMessage(render.errorMessage || 'AI 白底图请求已返回,但没有可用结果图。') + settleRenderTask(task.id, 'failed', '生成结束,但没有返回可用图片') } } catch (error) { setMessage(error instanceof Error ? error.message : 'AI 白底图生成失败') - } finally { - setGenerating(false) + settleRenderTask(task.id, 'failed', '生成失败,请稍后重试') } }, [ appendRenderedRecords, @@ -1147,8 +1268,10 @@ export default function AiRenderWorkbenchClient({ finishes }: AiRenderWorkbenchC modelNodeNames, negativePrompt, prompt, + settleRenderTask, selectedSku, selectedSpu, + startRenderTask, ]) const handleOptimizePrompt = useCallback(async () => { @@ -1203,7 +1326,10 @@ export default function AiRenderWorkbenchClient({ finishes }: AiRenderWorkbenchC }, [aspectRatio, capturePreview, optimizing, prompt]) const handleGenerateScene = useCallback(async () => { - if (!selectedSku || !selectedSku.modelUrl) { + if (generating) return + const taskSku = selectedSku + const taskSpu = selectedSpu + if (!taskSku || !taskSku.modelUrl) { setMessage('请先选择并配置一个 SKU 模型文件。') return } @@ -1222,61 +1348,76 @@ export default function AiRenderWorkbenchClient({ finishes }: AiRenderWorkbenchC setMessage('请先填写场景效果图提示词。') return } - - setGenerating(true) - setMessage('正在请求 AI 生图服务...') + 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, + }) + setMessage(buildTaskStartedMessage(task)) try { - const lockedScenePrompt = buildLockedScenePrompt({ - spu: selectedSpu, - sku: selectedSku, - userPrompt: normalizedPrompt, - aspectRatio, - }) const render = await createAssistedAssemblyAiRender({ prompt: lockedScenePrompt, - negativePrompt: mergeNegativePrompt(negativePrompt, CURVE_DETAIL_ENHANCEMENT_NEGATIVE_PROMPT), - imageCount, - aspectRatio, - imageSize, + negativePrompt: mergeNegativePrompt(taskNegativePrompt, CURVE_DETAIL_ENHANCEMENT_NEGATIVE_PROMPT), + imageCount: taskImageCount, + aspectRatio: taskAspectRatio, + imageSize: taskImageSize, referenceImageDataUrl, workspaceSnapshot: buildAiRenderWorkspaceSnapshot({ - spu: selectedSpu, - sku: selectedSku, + spu: taskSpu, + sku: taskSku, prompt: lockedScenePrompt, mode: 'scene', }), assetSnapshot: buildAiRenderAssetSnapshot({ - spu: selectedSpu, - sku: selectedSku, - nodeNames: modelNodeNames, + spu: taskSpu, + sku: taskSku, + nodeNames: taskNodeNames, }), }) const savedCount = appendRenderedRecords({ mode: 'scene', images: render.resultImages, + skuId: task.skuId, + skuName: task.skuName, prompt: lockedScenePrompt, }) if (render.status === 'SUCCEEDED' && savedCount > 0) { setMessage(savedCount > 1 ? `场景效果图已生成,共 ${savedCount} 张。` : '场景效果图已生成。') + settleRenderTask(task.id, 'succeeded', `已完成,生成 ${savedCount} 张图片`) } else { setMessage(render.errorMessage || 'AI 生图请求已返回,但没有可用结果图。') + settleRenderTask(task.id, 'failed', '生成结束,但没有返回可用图片') } } catch (error) { setMessage(error instanceof Error ? error.message : 'AI 生图失败') - } finally { - setGenerating(false) + settleRenderTask(task.id, 'failed', '生成失败,请稍后重试') } }, [ appendRenderedRecords, aspectRatio, capturePreview, + generating, imageCount, imageSize, modelNodeNames, negativePrompt, prompt, + settleRenderTask, selectedSku, selectedSpu, + startRenderTask, ]) const handleDeleteRecord = useCallback((recordId: string) => { @@ -1419,7 +1560,7 @@ export default function AiRenderWorkbenchClient({ finishes }: AiRenderWorkbenchC > {selectedSku?.modelUrl - ? '精准白底图会以当前 3D 视角为参考做 AI 曲面细节增强,场景图也会锁定产品结构。' + ? '白底图会以当前 3D 视角为参考做 AI 曲面细节增强,场景图也会锁定产品结构。' : '当前 SKU 还没有模型文件,先上传或粘贴模型 URL。'} } @@ -1492,10 +1633,11 @@ export default function AiRenderWorkbenchClient({ finishes }: AiRenderWorkbenchC aspectRatio={aspectRatio} imageCount={imageCount} generating={generating} + renderTask={renderTask} optimizing={optimizing} optimExplanation={optimExplanation} message={message} - records={records} + records={selectedSkuRecords} onSelectSpu={handleSelectSpu} onCreateSpu={handleCreateSpu} onRenameSpu={(spuId, name) => updateSpu(spuId, { name })} diff --git a/furniture-3d-system/src/app/ai-render-workbench/RenderInspector.tsx b/furniture-3d-system/src/app/ai-render-workbench/RenderInspector.tsx index 79103cc4..07f2c18f 100644 --- a/furniture-3d-system/src/app/ai-render-workbench/RenderInspector.tsx +++ b/furniture-3d-system/src/app/ai-render-workbench/RenderInspector.tsx @@ -22,7 +22,7 @@ import { } from 'lucide-react' import { Button } from '@/components/ui/Button' import { cn } from '@/lib/utils' -import type { AiRenderSku, AiRenderSpu, LocalRenderRecord } from './types' +import type { AiRenderSku, AiRenderSpu, AiRenderTaskState, LocalRenderRecord } from './types' interface RenderInspectorProps { spus: AiRenderSpu[] @@ -36,6 +36,7 @@ interface RenderInspectorProps { aspectRatio: string imageCount: number generating: boolean + renderTask: AiRenderTaskState | null optimizing: boolean optimExplanation: string message: string | null @@ -144,6 +145,9 @@ function formatRecordTime(value: string) { }) } +function getRenderTaskModeLabel(mode: LocalRenderRecord['mode']) { + return mode === 'white-background' ? '白底图' : '场景效果图' +} export function RenderInspector({ spus, selectedSpuId, @@ -156,6 +160,7 @@ export function RenderInspector({ aspectRatio, imageCount, generating, + renderTask, optimizing, optimExplanation, message, @@ -539,7 +544,6 @@ export function RenderInspector({