From 9dbb61cdd1e2882e34d47c311ad7b05ca469a1bd Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Mon, 15 Jun 2026 18:28:59 +0800 Subject: [PATCH] feat(ai-render): add render workbench --- .../AiRenderWorkbenchClient.tsx | 445 +++++++++++++ .../ai-render-workbench/RenderInspector.tsx | 607 ++++++++++++++++++ .../src/app/ai-render-workbench/page.tsx | 12 + .../src/app/ai-render-workbench/sampleData.ts | 37 ++ .../src/app/ai-render-workbench/types.ts | 37 ++ .../src/app/ai-render-workbench/utils.test.ts | 11 + .../src/app/ai-render-workbench/utils.ts | 133 ++++ .../src/components/layout/AppShell.tsx | 6 +- .../src/components/layout/AppSideNav.tsx | 2 + .../src/components/layout/AppTopNav.tsx | 2 + .../src/components/layout/MobileTabBar.tsx | 2 + .../layout/__tests__/AppShell.test.tsx | 14 +- .../layout/__tests__/AppTopNav.test.tsx | 11 +- .../src/components/platform/types.ts | 1 + 14 files changed, 1316 insertions(+), 4 deletions(-) create mode 100644 furniture-3d-system/src/app/ai-render-workbench/AiRenderWorkbenchClient.tsx create mode 100644 furniture-3d-system/src/app/ai-render-workbench/RenderInspector.tsx create mode 100644 furniture-3d-system/src/app/ai-render-workbench/page.tsx create mode 100644 furniture-3d-system/src/app/ai-render-workbench/sampleData.ts create mode 100644 furniture-3d-system/src/app/ai-render-workbench/types.ts create mode 100644 furniture-3d-system/src/app/ai-render-workbench/utils.test.ts create mode 100644 furniture-3d-system/src/app/ai-render-workbench/utils.ts diff --git a/furniture-3d-system/src/app/ai-render-workbench/AiRenderWorkbenchClient.tsx b/furniture-3d-system/src/app/ai-render-workbench/AiRenderWorkbenchClient.tsx new file mode 100644 index 00000000..db9d0870 --- /dev/null +++ b/furniture-3d-system/src/app/ai-render-workbench/AiRenderWorkbenchClient.tsx @@ -0,0 +1,445 @@ +'use client' + +import { useCallback, useMemo, useState } from 'react' +import { AlertCircle, Camera, Sparkles } from 'lucide-react' +import { AssetWorkbenchEmptyBackdrop } from '@/components/platform/AssetWorkbenchEmptyBackdrop' +import { ModelPreviewPanel } from '@/components/platform/ModelPreviewPanel' +import { + createAssistedAssemblyAiRender, + optimizeAiPromptStream, +} from '@/lib/open-product/platform-api' +import { extractPreviewModelFromZip } from '@/lib/model-preview-zip' +import { cn } from '@/lib/utils' +import { RenderInspector } from './RenderInspector' +import { AI_RENDER_SAMPLE_SPUS } from './sampleData' +import type { + AiRenderSku, + AiRenderSpu, + LocalRenderRecord, +} from './types' +import { + buildAiRenderAssetSnapshot, + buildAiRenderWorkspaceSnapshot, + buildSkuMaterialAssignments, + createId, + getDefaultSkuName, + getDefaultSpuName, + getProductStats, +} from './utils' + +function cloneSpus(spus: AiRenderSpu[]) { + return spus.map((spu) => ({ + ...spu, + skus: spu.skus.map((sku) => ({ ...sku })), + })) +} + +async function uploadSkuModelFile(file: File): Promise { + let uploadFile = file + const lowerName = file.name.toLowerCase() + + if (lowerName.endsWith('.zip')) { + const parsed = await extractPreviewModelFromZip(file) + if (!parsed.success) { + throw new Error(parsed.error || 'ZIP 解析失败') + } + uploadFile = parsed.modelFile + } + + const uploadFileName = uploadFile.name.toLowerCase() + if ( + !uploadFileName.endsWith('.glb') && + !uploadFileName.endsWith('.gltf') && + !uploadFileName.endsWith('.obj') + ) { + throw new Error('当前仅支持 .zip / .glb / .gltf / .obj 格式的 SKU 模型') + } + + const formData = new FormData() + formData.append('file', uploadFile) + const response = await fetch('/api/upload/model', { + method: 'POST', + body: formData, + }) + const payload = (await response.json().catch(() => null)) as { + success?: boolean + data?: { url?: string } + error?: { message?: string } + } | null + if (!response.ok || !payload?.success || !payload.data?.url) { + throw new Error(payload?.error?.message || 'SKU 模型上传失败') + } + return payload.data.url +} + +export default function AiRenderWorkbenchClient() { + const [spus, setSpus] = useState(() => cloneSpus(AI_RENDER_SAMPLE_SPUS)) + const [selectedSpuId, setSelectedSpuId] = useState(AI_RENDER_SAMPLE_SPUS[0]?.id ?? null) + const [selectedSkuId, setSelectedSkuId] = useState(AI_RENDER_SAMPLE_SPUS[0]?.skus[0]?.id ?? null) + const [modelNodeNames, setModelNodeNames] = useState([]) + const [capturePreview, setCapturePreview] = useState<(() => string | null) | null>(null) + const [prompt, setPrompt] = useState('modern minimalist interior, product photography, natural daylight, realistic material, clean composition') + const [negativePrompt, setNegativePrompt] = useState('deformed product, wrong structure, extra parts, watermark, text') + const [aspectRatio, setAspectRatio] = useState('1:1') + const [imageSize, setImageSize] = useState('2K') + const [imageCount, setImageCount] = useState(1) + const [records, setRecords] = useState([]) + const [message, setMessage] = useState(null) + const [generating, setGenerating] = useState(false) + const [optimizing, setOptimizing] = useState(false) + const [optimExplanation, setOptimExplanation] = useState('') + const [uploadingSkuId, setUploadingSkuId] = useState(null) + + const selectedSpu = useMemo( + () => spus.find((spu) => spu.id === selectedSpuId) ?? spus[0] ?? null, + [spus, selectedSpuId], + ) + const selectedSku = useMemo(() => { + const skus = selectedSpu?.skus ?? [] + return skus.find((sku) => sku.id === selectedSkuId) ?? skus[0] ?? null + }, [selectedSpu, selectedSkuId]) + const stats = useMemo(() => getProductStats(spus), [spus]) + const materialAssignments = useMemo( + () => buildSkuMaterialAssignments(selectedSku, modelNodeNames), + [modelNodeNames, selectedSku], + ) + + const handleCaptureReady = useCallback((capture: (() => string | null) | null) => { + setCapturePreview(() => capture) + }, []) + + const updateSpu = useCallback((spuId: string, patch: Partial) => { + setSpus((current) => + current.map((spu) => (spu.id === spuId ? { ...spu, ...patch } : spu)), + ) + }, []) + + const updateSku = useCallback((skuId: string, patch: Partial) => { + setSpus((current) => + current.map((spu) => ({ + ...spu, + skus: spu.skus.map((sku) => + sku.id === skuId ? { ...sku, ...patch } : sku, + ), + })), + ) + }, []) + + const handleCreateSpu = useCallback(() => { + const spuNo = spus.length + 1 + const newSpu: AiRenderSpu = { + id: createId('spu'), + name: getDefaultSpuName(spuNo), + description: '', + skus: [], + } + const firstSku: AiRenderSku = { + id: createId('sku'), + name: getDefaultSkuName(1), + modelUrl: '', + thumbnailUrl: null, + materialColor: '#d8c6ad', + } + newSpu.skus = [firstSku] + setSpus((current) => [...current, newSpu]) + setSelectedSpuId(newSpu.id) + setSelectedSkuId(firstSku.id) + setModelNodeNames([]) + }, [spus.length]) + + const handleDeleteSpu = useCallback((spuId: string) => { + setSpus((current) => { + const next = current.filter((spu) => spu.id !== spuId) + const fallbackSpu = next[0] ?? null + setSelectedSpuId(fallbackSpu?.id ?? null) + setSelectedSkuId(fallbackSpu?.skus[0]?.id ?? null) + setModelNodeNames([]) + return next + }) + }, []) + + const handleSelectSpu = useCallback((spuId: string) => { + const spu = spus.find((item) => item.id === spuId) + setSelectedSpuId(spuId) + setSelectedSkuId(spu?.skus[0]?.id ?? null) + setModelNodeNames([]) + }, [spus]) + + const handleCreateSku = useCallback(() => { + if (!selectedSpu) return + const newSku: AiRenderSku = { + id: createId('sku'), + name: getDefaultSkuName(selectedSpu.skus.length + 1), + modelUrl: '', + thumbnailUrl: null, + materialColor: '#d8c6ad', + } + setSpus((current) => + current.map((spu) => + spu.id === selectedSpu.id + ? { ...spu, skus: [...spu.skus, newSku] } + : spu, + ), + ) + setSelectedSkuId(newSku.id) + setModelNodeNames([]) + }, [selectedSpu]) + + const handleDeleteSku = useCallback((skuId: string) => { + if (!selectedSpu) return + setSpus((current) => + current.map((spu) => { + if (spu.id !== selectedSpu.id) return spu + const nextSkus = spu.skus.filter((sku) => sku.id !== skuId) + const fallbackSku = nextSkus[0] ?? null + setSelectedSkuId(fallbackSku?.id ?? null) + setModelNodeNames([]) + return { ...spu, skus: nextSkus } + }), + ) + }, [selectedSpu]) + + const handleUploadSkuModel = useCallback(async (skuId: string, file: File) => { + setUploadingSkuId(skuId) + setMessage(null) + try { + const url = await uploadSkuModelFile(file) + updateSku(skuId, { modelUrl: url }) + setSelectedSkuId(skuId) + setMessage('SKU 模型已上传,可以在主视窗预览。') + } catch (error) { + setMessage(error instanceof Error ? error.message : 'SKU 模型上传失败') + } finally { + setUploadingSkuId(null) + } + }, [updateSku]) + + const createLocalRecord = useCallback((params: { + mode: LocalRenderRecord['mode'] + imageUrl: string + status?: LocalRenderRecord['status'] + }) => { + if (!selectedSku) return + setRecords((current) => [ + { + id: createId('render'), + skuName: selectedSku.name || '未命名 SKU', + mode: params.mode, + imageUrl: params.imageUrl, + prompt, + status: params.status ?? 'SUCCEEDED', + createdAt: new Date().toISOString(), + }, + ...current, + ].slice(0, 18)) + }, [prompt, selectedSku]) + + const handleCaptureWhiteBackground = useCallback(() => { + const dataUrl = capturePreview?.() + if (!dataUrl) { + setMessage('当前 3D 预览还没准备好,稍等模型加载完成再试。') + return + } + createLocalRecord({ mode: 'white-background', imageUrl: dataUrl }) + setMessage('白底图已从当前 3D 视角截取,先保存在右侧本地记录里。') + }, [capturePreview, createLocalRecord]) + + const handleOptimizePrompt = useCallback(async () => { + const normalizedPrompt = prompt.trim() + if (!normalizedPrompt || optimizing) return + + setOptimizing(true) + setOptimExplanation('') + setMessage(null) + try { + const referenceImageDataUrl = capturePreview?.() ?? null + let accumulated = '' + await optimizeAiPromptStream( + normalizedPrompt, + referenceImageDataUrl, + (chunk) => { + accumulated += chunk + const sepIdx = accumulated.indexOf('---PROMPT---') + if (sepIdx >= 0) { + const explanation = accumulated.slice(0, sepIdx).trim() + const promptPart = accumulated.slice(sepIdx + '---PROMPT---'.length).trim() + if (explanation) setOptimExplanation(explanation) + if (promptPart) setPrompt(promptPart) + } else { + setOptimExplanation(accumulated.trim()) + } + }, + (_fullText, optimizedPrompt) => { + setPrompt(optimizedPrompt) + setMessage('提示词已优化,可继续编辑后生成。') + }, + (errorMessage) => { + setMessage(errorMessage) + }, + ) + } catch (error) { + setMessage(error instanceof Error ? error.message : '提示词优化失败') + } finally { + setOptimizing(false) + } + }, [capturePreview, optimizing, prompt]) + + const handleGenerateScene = useCallback(async () => { + if (!selectedSku || !selectedSku.modelUrl) { + setMessage('请先选择并配置一个 SKU 模型文件。') + return + } + const referenceImageDataUrl = capturePreview?.() + if (!referenceImageDataUrl) { + setMessage('当前 3D 预览还没准备好,无法作为 AI 参考图。') + return + } + const normalizedPrompt = prompt.trim() + if (!normalizedPrompt) { + setMessage('请先填写场景效果图提示词。') + return + } + + setGenerating(true) + setMessage('正在请求 AI 生图服务...') + try { + const render = await createAssistedAssemblyAiRender({ + prompt: normalizedPrompt, + negativePrompt: negativePrompt.trim() || null, + imageCount, + aspectRatio, + imageSize, + referenceImageDataUrl, + workspaceSnapshot: buildAiRenderWorkspaceSnapshot({ + spu: selectedSpu, + sku: selectedSku, + prompt: normalizedPrompt, + mode: 'scene', + }), + assetSnapshot: buildAiRenderAssetSnapshot({ + spu: selectedSpu, + sku: selectedSku, + nodeNames: modelNodeNames, + }), + }) + const firstImage = render.resultImages[0]?.originalUrl || render.resultImages[0]?.thumbnailUrl + if (render.status === 'SUCCEEDED' && firstImage) { + createLocalRecord({ mode: 'scene', imageUrl: firstImage }) + setMessage('场景效果图已生成。') + } else { + setMessage(render.errorMessage || 'AI 生图请求已返回,但没有可用结果图。') + } + } catch (error) { + setMessage(error instanceof Error ? error.message : 'AI 生图失败') + } finally { + setGenerating(false) + } + }, [ + aspectRatio, + capturePreview, + createLocalRecord, + imageCount, + imageSize, + modelNodeNames, + negativePrompt, + prompt, + selectedSku, + selectedSpu, + ]) + + return ( +
+
+ } + emptyBackdrop={} + onCaptureReady={handleCaptureReady} + onModelNodesChange={setModelNodeNames} + overlayTopRight={ +
+ + {stats.spuCount} SPU / {stats.configuredSkuCount}/{stats.skuCount} SKU 已配置 +
+ } + overlayBottomCenter={ +
+ + {selectedSku?.modelUrl + ? '白底图基于当前 3D 视角截取,场景图会把当前视角作为 AI 参考图。' + : '当前 SKU 还没有模型文件,先上传或粘贴模型 URL。'} +
+ } + /> +
+

+ WORKSPACE +

+

+ 先选 SKU · 改颜色贴图 · 再生成白底图或场景效果图 +

+
+
+ + updateSpu(spuId, { name })} + onDeleteSpu={handleDeleteSpu} + onSelectSku={(skuId) => { + setSelectedSkuId(skuId) + setModelNodeNames([]) + }} + onCreateSku={handleCreateSku} + onUpdateSkuById={updateSku} + onDeleteSku={handleDeleteSku} + onUploadSkuModel={handleUploadSkuModel} + onPromptChange={setPrompt} + onNegativePromptChange={setNegativePrompt} + onAspectRatioChange={setAspectRatio} + onImageSizeChange={setImageSize} + onImageCountChange={setImageCount} + onCaptureWhiteBackground={handleCaptureWhiteBackground} + onOptimizePrompt={handleOptimizePrompt} + onGenerateScene={handleGenerateScene} + onUpdateSku={(patch) => { + if (selectedSku) { + updateSku(selectedSku.id, patch) + } + }} + /> +
+ ) +} diff --git a/furniture-3d-system/src/app/ai-render-workbench/RenderInspector.tsx b/furniture-3d-system/src/app/ai-render-workbench/RenderInspector.tsx new file mode 100644 index 00000000..db8fc77d --- /dev/null +++ b/furniture-3d-system/src/app/ai-render-workbench/RenderInspector.tsx @@ -0,0 +1,607 @@ +'use client' + +import { useState } from 'react' +import Link from 'next/link' +import { + AlertCircle, + Box, + ChevronDown, + ChevronRight, + Cuboid, + Download, + Image as ImageIcon, + ImagePlus, + Loader2, + Plus, + RotateCcw, + Sparkles, + Trash2, + UploadCloud, + Wand2, +} from 'lucide-react' +import { Button } from '@/components/ui/Button' +import { cn } from '@/lib/utils' +import type { AiRenderSku, AiRenderSpu, LocalRenderRecord } from './types' + +interface RenderInspectorProps { + spus: AiRenderSpu[] + selectedSpuId: string | null + selectedSkuId: string | null + uploadingSkuId: string | null + spu: AiRenderSpu | null + sku: AiRenderSku | null + prompt: string + negativePrompt: string + aspectRatio: string + imageSize: string + imageCount: number + generating: boolean + optimizing: boolean + optimExplanation: string + message: string | null + records: LocalRenderRecord[] + onSelectSpu: (spuId: string) => void + onCreateSpu: () => void + onRenameSpu: (spuId: string, name: string) => void + onDeleteSpu: (spuId: string) => void + onSelectSku: (skuId: string) => void + onCreateSku: () => void + onUpdateSkuById: (skuId: string, patch: Partial) => void + onDeleteSku: (skuId: string) => void + onUploadSkuModel: (skuId: string, file: File) => void + onPromptChange: (value: string) => void + onNegativePromptChange: (value: string) => void + onAspectRatioChange: (value: string) => void + onImageSizeChange: (value: string) => void + onImageCountChange: (value: number) => void + onCaptureWhiteBackground: () => void + onOptimizePrompt: () => void + onGenerateScene: () => void + onUpdateSku: (patch: Partial) => void +} + +type InspectorTab = 'product' | 'ai' + +function formatRecordTime(value: string) { + const date = new Date(value) + if (Number.isNaN(date.getTime())) return '' + return date.toLocaleString('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) +} + +export function RenderInspector({ + spus, + selectedSpuId, + selectedSkuId, + uploadingSkuId, + spu, + sku, + prompt, + negativePrompt, + aspectRatio, + imageSize, + imageCount, + generating, + optimizing, + optimExplanation, + message, + records, + onSelectSpu, + onCreateSpu, + onRenameSpu, + onDeleteSpu, + onSelectSku, + onCreateSku, + onUpdateSkuById, + onDeleteSku, + onUploadSkuModel, + onPromptChange, + onNegativePromptChange, + onAspectRatioChange, + onImageSizeChange, + onImageCountChange, + onCaptureWhiteBackground, + onOptimizePrompt, + onGenerateScene, + onUpdateSku, +}: RenderInspectorProps) { + const [activeTab, setActiveTab] = useState('product') + const [promptCollapsed, setPromptCollapsed] = useState(false) + const [negativeCollapsed, setNegativeCollapsed] = useState(true) + const [optimExplanationCollapsed, setOptimExplanationCollapsed] = useState(false) + + return ( +