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 7d987944..c8843211 100644 --- a/furniture-3d-system/src/app/ai-render-workbench/AiRenderWorkbenchClient.tsx +++ b/furniture-3d-system/src/app/ai-render-workbench/AiRenderWorkbenchClient.tsx @@ -51,6 +51,19 @@ const DEFAULT_NEGATIVE_PROMPT = 'deformed product, wrong structure, extra parts, const LEGACY_SAMPLE_SPU_ID = 'spu-cj0001' const LEGACY_SAMPLE_SKU_IDS = new Set(['sku-cj000101', 'sku-cj000102', 'sku-cj000103']) +const CURVE_DETAIL_ENHANCEMENT_PROMPT = + 'Treat visible low-polygon CAD faceting as an export artifact, not as intended product design. Visually refine curved and circular edges so an intended round arc that appears as roughly 12 straight facets is rendered with a smoother high-resolution appearance closer to 36 facets. Preserve the exact product structure, overall dimensions, straight edges, flat panels, hard corners, holes, slots, seams, material grain direction, texture placement, and part boundaries.' +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' + +function mergeNegativePrompt(...parts: Array): string | null { + const normalized = parts + .map((part) => part?.trim()) + .filter((part): part is string => Boolean(part)) + + return normalized.length > 0 ? normalized.join(', ') : null +} + type StoredWorkbenchState = { spus: AiRenderSpu[] selectedSpuId: string | null @@ -360,6 +373,7 @@ function buildWhiteBackgroundPrompt(params: { spu: AiRenderSpu | null sku: AiRenderSku userPrompt: string + aspectRatio: string }): string { const materialNames = uniqueNodeNames( Object.values(params.sku.nodeMaterials ?? {}).map((material) => material.materialName), @@ -369,10 +383,13 @@ function buildWhiteBackgroundPrompt(params: { .filter(Boolean) .join(' / ') const userPrompt = params.userPrompt.trim() + const aspectRatio = params.aspectRatio.trim() return [ 'Create a realistic studio product image from this 3D furniture product reference.', 'Keep the same product structure, proportions, camera angle, material, color, texture direction, hardware, seams, and details.', + aspectRatio ? `Final output must be exactly ${aspectRatio}.` : '', + CURVE_DETAIL_ENHANCEMENT_PROMPT, 'Remove the UI, viewport grid, shadows from the interface, and any unrelated background.', 'Use a clean pure white background, centered composition, soft product-photography lighting, and natural contact shadow only.', productLabel ? `Product: ${productLabel}.` : '', @@ -388,6 +405,7 @@ function buildLockedScenePrompt(params: { spu: AiRenderSpu | null sku: AiRenderSku userPrompt: string + aspectRatio: string }): string { const productLabel = [params.spu?.name, params.sku.name] .map((item) => item?.trim()) @@ -397,10 +415,13 @@ function buildLockedScenePrompt(params: { Object.values(params.sku.nodeMaterials ?? {}).map((material) => material.materialName), ).slice(0, 8) const scenePrompt = params.userPrompt.trim() + const aspectRatio = params.aspectRatio.trim() return [ 'Generate a realistic lifestyle scene for this furniture product while keeping the product locked to the reference image.', 'The product geometry must not be redesigned: keep the exact silhouette, proportions, chamfers, bevels, holes, seams, panel thickness, hardware, material color, grain direction, and visible structural details from the reference render.', + aspectRatio ? `Final output must be exactly ${aspectRatio}.` : '', + CURVE_DETAIL_ENHANCEMENT_PROMPT, 'Use the user request only for the surrounding scene, lighting, camera mood, and background styling. Do not let the prompt alter the product body.', productLabel ? `Product: ${productLabel}.` : '', materialNames.length > 0 ? `Visible product materials: ${materialNames.join(', ')}.` : '', @@ -1017,28 +1038,43 @@ export default function AiRenderWorkbenchClient({ finishes }: AiRenderWorkbenchC } }, [spus, updateSku]) - const createLocalRecord = useCallback((params: { + const appendRenderedRecords = useCallback((params: { mode: LocalRenderRecord['mode'] - imageUrl: string + images: Array<{ originalUrl?: string | null; thumbnailUrl?: string | null }> prompt?: string status?: LocalRenderRecord['status'] }) => { - if (!selectedSku) return - setRecords((current) => [ - { - id: createId('render'), - skuName: selectedSku.name || '未命名 SKU', - mode: params.mode, - imageUrl: params.imageUrl, - prompt: params.prompt ?? prompt, - status: params.status ?? 'SUCCEEDED', - createdAt: new Date().toISOString(), - }, - ...current, - ].slice(0, 18)) + if (!selectedSku) return 0 + const createdAt = new Date().toISOString() + const nextRecords = params.images + .map((image) => { + const imageUrl = + typeof image.originalUrl === 'string' && image.originalUrl.trim() + ? image.originalUrl.trim() + : typeof image.thumbnailUrl === 'string' && image.thumbnailUrl.trim() + ? image.thumbnailUrl.trim() + : '' + if (!imageUrl) return null + return { + id: createId('render'), + skuName: selectedSku.name || '未命名 SKU', + mode: params.mode, + imageUrl, + prompt: params.prompt ?? prompt, + status: params.status ?? 'SUCCEEDED', + createdAt, + } + }) + .filter((record): record is LocalRenderRecord => record !== null) + + if (nextRecords.length === 0) return 0 + + setRecords((current) => [...nextRecords, ...current].slice(0, 18)) + return nextRecords.length }, [prompt, selectedSku]) - const handleCaptureWhiteBackground = useCallback(() => { + const handleCaptureWhiteBackground = useCallback(async () => { + if (generating) return if (!selectedSku || !selectedSku.modelUrl) { setMessage('请先选择并配置一个 SKU 模型文件。') return @@ -1057,10 +1093,63 @@ export default function AiRenderWorkbenchClient({ finishes }: AiRenderWorkbenchC spu: selectedSpu, sku: selectedSku, userPrompt: prompt, + aspectRatio, }) - createLocalRecord({ mode: 'white-background', imageUrl: dataUrl, prompt: lockedPrompt }) - setMessage('精准白底图已由 3D 模型渲染生成,产品结构未经过 AI 重绘。') - }, [capturePreview, createLocalRecord, prompt, selectedSku, selectedSpu]) + + setGenerating(true) + setMessage('正在基于当前 3D 参考图生成白底图,并增强低分段圆弧细节...') + try { + const render = await createAssistedAssemblyAiRender({ + prompt: lockedPrompt, + negativePrompt: mergeNegativePrompt(negativePrompt, CURVE_DETAIL_ENHANCEMENT_NEGATIVE_PROMPT), + imageCount, + aspectRatio, + imageSize, + referenceImageDataUrl: dataUrl, + workspaceSnapshot: buildAiRenderWorkspaceSnapshot({ + spu: selectedSpu, + sku: selectedSku, + prompt: lockedPrompt, + mode: 'white-background', + }), + assetSnapshot: buildAiRenderAssetSnapshot({ + spu: selectedSpu, + sku: selectedSku, + nodeNames: modelNodeNames, + }), + }) + const savedCount = appendRenderedRecords({ + mode: 'white-background', + images: render.resultImages, + prompt: lockedPrompt, + }) + if (render.status === 'SUCCEEDED' && savedCount > 0) { + setMessage( + savedCount > 1 + ? `精准白底图已生成,共 ${savedCount} 张,并按当前模型参考增强了低分段圆弧的视觉细节。` + : '精准白底图已生成,并按当前模型参考增强了低分段圆弧的视觉细节。', + ) + } else { + setMessage(render.errorMessage || 'AI 白底图请求已返回,但没有可用结果图。') + } + } catch (error) { + setMessage(error instanceof Error ? error.message : 'AI 白底图生成失败') + } finally { + setGenerating(false) + } + }, [ + appendRenderedRecords, + aspectRatio, + capturePreview, + generating, + imageCount, + imageSize, + modelNodeNames, + negativePrompt, + prompt, + selectedSku, + selectedSpu, + ]) const handleOptimizePrompt = useCallback(async () => { const normalizedPrompt = prompt.trim() @@ -1076,6 +1165,11 @@ export default function AiRenderWorkbenchClient({ finishes }: AiRenderWorkbenchC sceneBackground: 0xffffff, sceneGridVisible: false, }) ?? null + const optimizationTags: Record = { + 产品锁定: '基于当前 3D 参考图优化提示词,必须保留产品结构、材质方向、孔槽、尺寸比例', + 曲面增强: '把低分段圆弧当作 CAD 导出瑕疵进行视觉增强,不要重设计产品', + 输出比例: `最终出图比例必须匹配 ${aspectRatio}`, + } let accumulated = '' await optimizeAiPromptStream( normalizedPrompt, @@ -1099,13 +1193,14 @@ export default function AiRenderWorkbenchClient({ finishes }: AiRenderWorkbenchC (errorMessage) => { setMessage(errorMessage) }, + optimizationTags, ) } catch (error) { setMessage(error instanceof Error ? error.message : '提示词优化失败') } finally { setOptimizing(false) } - }, [capturePreview, optimizing, prompt]) + }, [aspectRatio, capturePreview, optimizing, prompt]) const handleGenerateScene = useCallback(async () => { if (!selectedSku || !selectedSku.modelUrl) { @@ -1135,10 +1230,11 @@ export default function AiRenderWorkbenchClient({ finishes }: AiRenderWorkbenchC spu: selectedSpu, sku: selectedSku, userPrompt: normalizedPrompt, + aspectRatio, }) const render = await createAssistedAssemblyAiRender({ prompt: lockedScenePrompt, - negativePrompt: negativePrompt.trim() || null, + negativePrompt: mergeNegativePrompt(negativePrompt, CURVE_DETAIL_ENHANCEMENT_NEGATIVE_PROMPT), imageCount, aspectRatio, imageSize, @@ -1155,10 +1251,13 @@ export default function AiRenderWorkbenchClient({ finishes }: AiRenderWorkbenchC nodeNames: modelNodeNames, }), }) - const firstImage = render.resultImages[0]?.originalUrl || render.resultImages[0]?.thumbnailUrl - if (render.status === 'SUCCEEDED' && firstImage) { - createLocalRecord({ mode: 'scene', imageUrl: firstImage, prompt: lockedScenePrompt }) - setMessage('场景效果图已生成。') + const savedCount = appendRenderedRecords({ + mode: 'scene', + images: render.resultImages, + prompt: lockedScenePrompt, + }) + if (render.status === 'SUCCEEDED' && savedCount > 0) { + setMessage(savedCount > 1 ? `场景效果图已生成,共 ${savedCount} 张。` : '场景效果图已生成。') } else { setMessage(render.errorMessage || 'AI 生图请求已返回,但没有可用结果图。') } @@ -1168,9 +1267,9 @@ export default function AiRenderWorkbenchClient({ finishes }: AiRenderWorkbenchC setGenerating(false) } }, [ + appendRenderedRecords, aspectRatio, capturePreview, - createLocalRecord, imageCount, imageSize, modelNodeNames, @@ -1197,6 +1296,7 @@ export default function AiRenderWorkbenchClient({ finishes }: AiRenderWorkbenchC title="AI 出图工作台" modelUrl={selectedSku?.modelUrl || null} preserveDrawingBuffer + repairDisplayNormals showFpsBadge={false} className="h-full" defaultShowSceneGrid={false} @@ -1319,7 +1419,7 @@ export default function AiRenderWorkbenchClient({ finishes }: AiRenderWorkbenchC > {selectedSku?.modelUrl - ? '精准白底图直接来自 3D 模型渲染,场景图继续用当前视角做 AI 参考。' + ? '精准白底图会以当前 3D 视角为参考做 AI 曲面细节增强,场景图也会锁定产品结构。' : '当前 SKU 还没有模型文件,先上传或粘贴模型 URL。'} } 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 f6ca6760..79103cc4 100644 --- a/furniture-3d-system/src/app/ai-render-workbench/RenderInspector.tsx +++ b/furniture-3d-system/src/app/ai-render-workbench/RenderInspector.tsx @@ -1,7 +1,6 @@ 'use client' import { useEffect, useMemo, useRef, useState } from 'react' -import Link from 'next/link' import { AlertCircle, Box, @@ -259,23 +258,6 @@ export function RenderInspector({