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

975 lines
31 KiB
TypeScript

'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import * as THREE from 'three'
import type { ModelPreviewCapture } from '@/components/platform/ModelPreviewPanel'
import {
createAiRenderWorkbenchDetailReference,
deleteAiRenderWorkbenchDetailReference,
} from '@/lib/open-product/platform-api'
import { CameraController, type CameraViewSnapshot } from '@/lib/3d/camera-controller'
import type { ThreeDEngine } from '@/lib/3d/engine'
import type { AiRenderWorkbenchAccess } from '@/lib/ai-render-workbench-permissions'
import type { MaterialDTO } from '@/server/services'
import type { MaterialPartLocatorGuide } from './promptBuilders'
import { RenderInspector } from './RenderInspector'
import { WorkbenchPreviewStage } from './WorkbenchPreviewStage'
import { WorkbenchReferenceOverlay } from './WorkbenchReferenceOverlay'
import {
collectMaterialLocatorTargetMeshes,
collectRenderableMeshes,
type MaterialPartLocatorReference,
type MeshMaterialSnapshot,
} from './materialLocator'
import {
cloneCameraView,
createReferenceCaptureView,
type AiRenderImageCropRect,
type ProjectBoundsToImageRectOptions,
} from './referenceGeometry'
import {
buildProjectedCropRectFromImage as buildProjectedCropRectFromImageUtil,
cropReferenceImageToDataUrl,
} from './referenceImageUtils'
import { useDetailSelectionController } from './useDetailSelectionController'
import { useMaterialPartAnnotationsController } from './useMaterialPartAnnotationsController'
import { runEightViewGeneration, runEightViewRegeneration } from './eightViewGeneration'
import { runPromptOptimization } from './promptOptimization'
import { useRenderRecordsController } from './useRenderRecordsController'
import { runSceneGeneration } from './sceneGeneration'
import { usePreviewInteractionController } from './usePreviewInteractionController'
import {
readInitialWorkbenchState,
useAiRenderWorkbenchCatalogController,
} from './useAiRenderWorkbenchCatalogController'
import { useAiRenderTaskController } from './useAiRenderTaskController'
import { runWhiteBackgroundGeneration } from './whiteBackgroundGeneration'
import { compactNodeMaterial, waitForNextFrame } from './workbenchHelpers'
import {
AI_RENDER_DEFAULT_IMAGE_SIZE,
AI_RENDER_WORKBENCH_UI_STORAGE_KEY,
DEFAULT_MATERIAL_COLOR,
DEFAULT_NEGATIVE_PROMPT,
DEFAULT_PROMPT,
normalizeWorkbenchImageSize,
} from './workbenchState'
import type {
AiRenderCameraViewSnapshot,
AiRenderMaterialPartAnnotation,
AiRenderNodeMaterial,
AiRenderSku,
AiRenderTemporaryReferenceImage,
LocalRenderRecord,
} from './types'
import { buildSkuMaterialAssignments, getProductStats, uniqueNodeNames } from './utils'
import { useMaterialPanelController } from './useMaterialPanelController'
const WHITE_BACKGROUND_CAPTURE_OPTIONS = {
clearColor: 0xffffff,
clearAlpha: 1,
sceneBackground: 0xffffff,
sceneGridVisible: false,
captureProfile: 'studio-product',
hideHelpers: true,
} as const
const AI_RENDER_WORKBENCH_DEFAULT_VIEW_PADDING = 1.05
const AI_RENDER_STRUCTURE_REFERENCE_IMAGE_MAX_COUNT = 4
const DETAIL_SELECTION_EXIT_MESSAGE = '已退出特写框选,右键模型可继续更换基材和调整 UV。'
const MATERIAL_PART_LOCATOR_PALETTE = [
{ colorName: 'magenta', color: 0xff00ff },
{ colorName: 'cyan', color: 0x00d7ff },
{ colorName: 'lime green', color: 0x6dff00 },
{ colorName: 'yellow', color: 0xffe500 },
{ colorName: 'orange', color: 0xff7a00 },
{ colorName: 'blue', color: 0x356dff },
] as const
interface AiRenderWorkbenchClientProps {
finishes: MaterialDTO[]
initialAccess: AiRenderWorkbenchAccess
}
type NodeMaterialPatch = Partial<AiRenderNodeMaterial> | ((
currentMaterial: AiRenderNodeMaterial | null,
nodeName: string,
sku: AiRenderSku,
) => Partial<AiRenderNodeMaterial>)
export default function AiRenderWorkbenchClient({
finishes,
initialAccess,
}: AiRenderWorkbenchClientProps) {
const [{ legacyStoredWorkbenchState, storedUiState }] = useState(readInitialWorkbenchState)
const [modelNodeNames, setModelNodeNames] = useState<string[]>([])
const [capturePreview, setCapturePreview] = useState<ModelPreviewCapture | null>(null)
const engineRef = useRef<ThreeDEngine | null>(null)
const cameraControllerRef = useRef<CameraController | null>(null)
const modelRootRef = useRef<THREE.Object3D | null>(null)
const previousSelectedSkuIdRef = useRef<string | null>(null)
const [prompt, setPrompt] = useState(
storedUiState?.prompt ?? legacyStoredWorkbenchState?.prompt ?? DEFAULT_PROMPT,
)
const [negativePrompt, setNegativePrompt] = useState(
storedUiState?.negativePrompt ??
legacyStoredWorkbenchState?.negativePrompt ??
DEFAULT_NEGATIVE_PROMPT,
)
const [aspectRatio, setAspectRatio] = useState(
storedUiState?.aspectRatio ?? legacyStoredWorkbenchState?.aspectRatio ?? '1:1',
)
const [imageSize] = useState(
normalizeWorkbenchImageSize(
storedUiState?.imageSize ??
legacyStoredWorkbenchState?.imageSize ??
AI_RENDER_DEFAULT_IMAGE_SIZE,
),
)
const [imageCount, setImageCount] = useState(
storedUiState?.imageCount ?? legacyStoredWorkbenchState?.imageCount ?? 1,
)
const [detailReferenceImagesBySkuId, setDetailReferenceImagesBySkuId] = useState<
Record<string, AiRenderTemporaryReferenceImage>
>(() => storedUiState?.detailReferenceImagesBySkuId ?? {})
const [materialPartAnnotationsBySkuId, setMaterialPartAnnotationsBySkuId] = useState<
Record<string, AiRenderMaterialPartAnnotation[]>
>(() => storedUiState?.materialPartAnnotationsBySkuId ?? {})
const [materialPartAnnotationInput, setMaterialPartAnnotationInput] = useState('')
const [message, setMessage] = useState<string | null>(null)
const [optimizing, setOptimizing] = useState(false)
const [optimExplanation, setOptimExplanation] = useState('')
const [selectedNodeName, setSelectedNodeName] = useState<string | null>(null)
const [selectedNodeNames, setSelectedNodeNames] = useState<string[]>([])
const {
workbenchLoading,
spus,
uploadingSkuId,
selectedSpu,
selectedSku,
canWriteConfig,
canGenerate,
canDeleteImages,
setSelectedSpuId,
setSelectedSkuId,
updateSpu,
updateSku,
createSpu,
deleteSpu,
selectSpu,
createSku,
deleteSku,
uploadSkuModel,
} = useAiRenderWorkbenchCatalogController({
initialAccess,
legacyStoredWorkbenchState,
storedUiState,
setMessage,
})
const stats = useMemo(() => getProductStats(spus), [spus])
const materialAssignments = useMemo(
() => buildSkuMaterialAssignments(selectedSku, modelNodeNames),
[modelNodeNames, selectedSku],
)
const selectedSkuDetailReference = useMemo(
() => (selectedSku ? detailReferenceImagesBySkuId[selectedSku.id] ?? null : null),
[detailReferenceImagesBySkuId, selectedSku],
)
const selectedSkuMaterialPartAnnotations = useMemo(
() => (selectedSku ? materialPartAnnotationsBySkuId[selectedSku.id] ?? [] : []),
[materialPartAnnotationsBySkuId, selectedSku],
)
const {
selectedRenderTask,
generating: localGenerating,
startRenderTask,
setMessageForSku,
settleRenderTask,
updateRunningRenderTask,
} = useAiRenderTaskController({
activeSkuId: selectedSku?.id ?? null,
setMessage,
})
useEffect(() => {
if (typeof window === 'undefined') return
const snapshot = {
selectedSpuId: selectedSpu?.id ?? null,
selectedSkuId: selectedSku?.id ?? null,
prompt,
negativePrompt,
aspectRatio,
imageSize: normalizeWorkbenchImageSize(imageSize),
imageCount,
detailReferenceImagesBySkuId,
materialPartAnnotationsBySkuId,
}
window.localStorage.setItem(AI_RENDER_WORKBENCH_UI_STORAGE_KEY, JSON.stringify(snapshot))
}, [
aspectRatio,
detailReferenceImagesBySkuId,
imageCount,
imageSize,
materialPartAnnotationsBySkuId,
negativePrompt,
prompt,
selectedSku?.id,
selectedSpu?.id,
])
useEffect(() => {
const currentSkuId = selectedSku?.id ?? null
if (!currentSkuId) return
if (previousSelectedSkuIdRef.current === null) {
previousSelectedSkuIdRef.current = currentSkuId
return
}
if (previousSelectedSkuIdRef.current !== currentSkuId) {
previousSelectedSkuIdRef.current = currentSkuId
setPrompt(DEFAULT_PROMPT)
setNegativePrompt(DEFAULT_NEGATIVE_PROMPT)
setOptimExplanation('')
setMessage(null)
}
}, [selectedSku?.id])
const handleCaptureReady = useCallback((capture: ModelPreviewCapture | null) => {
setCapturePreview(() => capture)
if (!capture) {
cameraControllerRef.current?.dispose()
cameraControllerRef.current = null
engineRef.current = null
modelRootRef.current = null
}
}, [])
const handleEngineReady = useCallback((payload: { engine: ThreeDEngine; modelRoot: unknown }) => {
engineRef.current = payload.engine
modelRootRef.current = payload.modelRoot as THREE.Object3D
cameraControllerRef.current?.dispose()
const controller = new CameraController(
payload.engine.getCamera(),
payload.engine.getControls(),
payload.engine.getScene(),
)
controller.setDefaultAnimConfig({ duration: 0, easing: 'linear' })
controller.setNavigationModel(payload.modelRoot as THREE.Object3D)
cameraControllerRef.current = controller
const defaultView = controller.buildFittedViewFromSnapshot(controller.saveView(), {
padding: AI_RENDER_WORKBENCH_DEFAULT_VIEW_PADDING,
})
if (defaultView) {
void controller.transitionToView(defaultView, { transition: 'instant', duration: 0 })
}
}, [])
const updateSelectedNodeMaterials = useCallback(
(nodeNames: string[], patch: NodeMaterialPatch) => {
if (!canWriteConfig) return
if (!selectedSku) return
const targetNodeNames = uniqueNodeNames(nodeNames)
if (targetNodeNames.length === 0) return
const nodeMaterials = { ...(selectedSku.nodeMaterials ?? {}) }
targetNodeNames.forEach((nodeName) => {
const currentNodeMaterial = nodeMaterials[nodeName] ?? null
const resolvedPatch =
typeof patch === 'function' ? patch(currentNodeMaterial, nodeName, selectedSku) : patch
const baseNodeMaterial = {
color: currentNodeMaterial?.color || selectedSku.materialColor || DEFAULT_MATERIAL_COLOR,
textureUrl: currentNodeMaterial?.textureUrl ?? selectedSku.materialTextureUrl ?? '',
}
nodeMaterials[nodeName] = compactNodeMaterial({
...baseNodeMaterial,
...currentNodeMaterial,
...resolvedPatch,
})
})
updateSku(selectedSku.id, { nodeMaterials })
},
[canWriteConfig, selectedSku, updateSku],
)
const {
materialPanel,
uvEditorForm,
uvConfigDirty,
materialPanelSubtitle,
materialPanelHasMaterialConfig,
materialPanelPosition,
materialSubmenuPosition,
uvSubmenuPosition,
contextMenuGroups,
materialMenuItems,
closeMaterialPanel,
openMaterialPanel,
handleContextMenuItemClick,
handleContextMenuHover,
handleMaterialSelect,
handlePatchUvEditorForm,
handleSelectUvProjectionAxis,
handleKeepUvDraft,
handleResetUvEditor,
handleClearUvEditor,
syncMaterialPanelWithModelNodes,
} = useMaterialPanelController({
canWriteConfig,
finishes,
modelNodeNames,
selectedSku,
setSelectedNodeName,
setSelectedNodeNames,
setMessage,
updateSelectedNodeMaterials,
})
const {
resetPreviewSelection,
handlePreviewModelNodesChange,
handlePreviewNodeSelect,
handlePreviewNodeContextMenu,
} = usePreviewInteractionController({
canWriteConfig,
modelNodeNames,
selectedNodeNames,
closeMaterialPanel,
openMaterialPanel,
syncMaterialPanelWithModelNodes,
setMessage,
setModelNodeNames,
setSelectedNodeName,
setSelectedNodeNames,
})
const {
selectedSkuRecords,
hasPendingSelectedSku,
appendServerRenderRecords,
handleDeleteRecord,
handleRefreshRecords,
} = useRenderRecordsController({
canDeleteImages,
selectedSku,
setMessage,
})
const generating = localGenerating || hasPendingSelectedSku
const handleCreateSpu = useCallback(async () => {
if (await createSpu()) {
resetPreviewSelection()
}
}, [createSpu, resetPreviewSelection])
const handleDeleteSpu = useCallback(
async (spuId: string) => {
if (await deleteSpu(spuId)) {
resetPreviewSelection()
}
},
[deleteSpu, resetPreviewSelection],
)
const handleSelectSpu = useCallback(
(spuId: string) => {
selectSpu(spuId)
resetPreviewSelection()
},
[resetPreviewSelection, selectSpu],
)
const handleCreateSku = useCallback(async () => {
if (await createSku()) {
resetPreviewSelection()
}
}, [createSku, resetPreviewSelection])
const handleDeleteSku = useCallback(
async (skuId: string) => {
if (await deleteSku(skuId)) {
resetPreviewSelection()
}
},
[deleteSku, resetPreviewSelection],
)
const handleUploadSkuModel = useCallback(
async (skuId: string, file: File) => {
if (await uploadSkuModel(skuId, file)) {
resetPreviewSelection()
}
},
[resetPreviewSelection, uploadSkuModel],
)
const handleSelectSku = useCallback(
(skuId: string) => {
const parentSpu = spus.find((spuItem) => spuItem.skus.some((skuItem) => skuItem.id === skuId))
if (parentSpu) {
setSelectedSpuId(parentSpu.id)
}
setSelectedSkuId(skuId)
resetPreviewSelection()
},
[resetPreviewSelection, setSelectedSkuId, setSelectedSpuId, spus],
)
const captureReferenceAtView = useCallback(
async (
view: CameraViewSnapshot,
options?: {
controller?: CameraController | null
capture?: ModelPreviewCapture | null
fitBounds?: THREE.Box3 | null
restoreAfterCapture?: boolean
},
): Promise<string | null> => {
const controller = options?.controller ?? cameraControllerRef.current
const capture = options?.capture ?? capturePreview
if (!controller || !capture) return null
const restoreView = options?.restoreAfterCapture ? controller.saveView() : null
const framedView = createReferenceCaptureView(controller, view, options?.fitBounds)
try {
await controller.transitionToView(framedView, { transition: 'instant', duration: 0 })
await waitForNextFrame()
return capture(WHITE_BACKGROUND_CAPTURE_OPTIONS)
} finally {
if (restoreView) {
await controller
.restoreView(restoreView, { transition: 'instant', duration: 0 })
.catch(() => undefined)
}
}
},
[capturePreview],
)
const buildMaterialPartLocatorReferenceImage = useCallback(
async (
annotations: AiRenderMaterialPartAnnotation[],
referenceView: AiRenderCameraViewSnapshot | null,
): Promise<MaterialPartLocatorReference | null> => {
const engine = engineRef.current
const modelRoot = modelRootRef.current ?? engine?.getModelRoot() ?? null
if (!modelRoot || !capturePreview) return null
const normalizedAnnotations = annotations
.map((annotation) => ({
nodeNames: uniqueNodeNames(annotation.nodeNames),
instruction: annotation.instruction.trim(),
}))
.filter((annotation) => annotation.nodeNames.length > 0 && annotation.instruction.length > 0)
if (normalizedAnnotations.length === 0) return null
const allMeshes = collectRenderableMeshes(modelRoot)
if (allMeshes.length === 0) return null
const colorByMeshUuid = new Map<string, number>()
const guides: MaterialPartLocatorGuide[] = []
normalizedAnnotations.forEach((annotation, index) => {
const targetMeshes = collectMaterialLocatorTargetMeshes(
engine,
modelRoot,
annotation.nodeNames,
)
if (targetMeshes.length === 0) return
const marker = MATERIAL_PART_LOCATOR_PALETTE[index % MATERIAL_PART_LOCATOR_PALETTE.length]!
targetMeshes.forEach((mesh) => {
colorByMeshUuid.set(mesh.uuid, marker.color)
})
guides.push({
colorName: marker.colorName,
nodeNames: annotation.nodeNames,
instruction: annotation.instruction,
})
})
if (colorByMeshUuid.size === 0 || guides.length === 0) return null
const snapshots: MeshMaterialSnapshot[] = []
const tempMaterials = new Map<number, THREE.MeshBasicMaterial>()
const contextMaterial = new THREE.MeshBasicMaterial({
color: 0xd8dbe0,
toneMapped: false,
})
const getMarkerMaterial = (color: number): THREE.MeshBasicMaterial => {
const existing = tempMaterials.get(color)
if (existing) return existing
const material = new THREE.MeshBasicMaterial({
color,
toneMapped: false,
})
tempMaterials.set(color, material)
return material
}
try {
allMeshes.forEach((mesh) => {
snapshots.push({ mesh, material: mesh.material })
const markerColor = colorByMeshUuid.get(mesh.uuid)
mesh.material = markerColor ? getMarkerMaterial(markerColor) : contextMaterial
})
await waitForNextFrame()
const imageDataUrl =
referenceView && cameraControllerRef.current
? await captureReferenceAtView(referenceView, {
controller: cameraControllerRef.current,
capture: capturePreview,
restoreAfterCapture: true,
})
: capturePreview(WHITE_BACKGROUND_CAPTURE_OPTIONS)
return imageDataUrl ? { imageDataUrl, guides } : null
} finally {
snapshots.forEach(({ mesh, material }) => {
mesh.material = material
if (Array.isArray(material)) {
material.forEach((item) => {
item.needsUpdate = true
})
} else {
material.needsUpdate = true
}
})
contextMaterial.dispose()
tempMaterials.forEach((material) => material.dispose())
await waitForNextFrame()
}
},
[capturePreview, captureReferenceAtView],
)
const buildProjectedCropRectFromImage = useCallback(
async (
referenceImageDataUrl: string,
fitBounds: THREE.Box3 | null | undefined,
referenceView: CameraViewSnapshot,
options?: ProjectBoundsToImageRectOptions,
): Promise<AiRenderImageCropRect | null> => {
return buildProjectedCropRectFromImageUtil({
sourceCamera: engineRef.current?.getCamera() ?? null,
referenceImageDataUrl,
fitBounds,
referenceView,
options,
})
},
[],
)
const buildProjectedDetailReferenceFromImage = useCallback(
async (
referenceImageDataUrl: string,
fitBounds: THREE.Box3 | null | undefined,
referenceView: CameraViewSnapshot,
): Promise<string | null> => {
const cropRect = await buildProjectedCropRectFromImage(
referenceImageDataUrl,
fitBounds,
referenceView,
)
return cropReferenceImageToDataUrl(referenceImageDataUrl, cropRect)
},
[buildProjectedCropRectFromImage],
)
const getCurrentCameraView = useCallback((): AiRenderCameraViewSnapshot | null => {
const controller = cameraControllerRef.current
return controller ? cloneCameraView(controller.saveView()) : null
}, [])
const commitSelectedSkuDetailReference = useCallback(
(image: AiRenderTemporaryReferenceImage | null) => {
const skuId = selectedSku?.id
if (!skuId) return
setDetailReferenceImagesBySkuId((current) => {
if (!image) {
if (!(skuId in current)) return current
const next = { ...current }
delete next[skuId]
return next
}
return {
...current,
[skuId]: image,
}
})
},
[selectedSku?.id],
)
const captureCurrentDetailReferenceImage = useCallback(
() => capturePreview?.(WHITE_BACKGROUND_CAPTURE_OPTIONS) ?? null,
[capturePreview],
)
const {
detailSelectionMode,
previewDetailSelectionRect,
startDetailSelectionMode,
handleExitDetailSelection,
handleClearDetailSelection,
handleDetailSelectionPointerDown,
handleDetailSelectionPointerMove,
handleDetailSelectionPointerUp,
handleDetailSelectionPointerCancel,
handleDetailSelectionContextMenu,
} = useDetailSelectionController({
selectedSku,
selectedSkuDetailReference,
exitMessage: DETAIL_SELECTION_EXIT_MESSAGE,
captureCurrentReferenceImage: captureCurrentDetailReferenceImage,
onCreateDetailReference: createAiRenderWorkbenchDetailReference,
onDeleteDetailReference: deleteAiRenderWorkbenchDetailReference,
onCommitDetailReference: commitSelectedSkuDetailReference,
onMessage: (nextMessage) => setMessage(nextMessage),
})
const {
handleAddMaterialPartAnnotation,
handleRemoveMaterialPartAnnotation,
handleClearMaterialPartAnnotations,
} = useMaterialPartAnnotationsController({
selectedSku,
selectedNodeName,
selectedNodeNames,
materialPartAnnotationInput,
onSetAnnotationsBySkuId: setMaterialPartAnnotationsBySkuId,
onMessage: (nextMessage) => setMessage(nextMessage),
})
const handleCaptureWhiteBackground = useCallback(async () => {
await runWhiteBackgroundGeneration({
canGenerate,
generating,
selectedSku,
selectedSpu,
prompt,
negativePrompt,
aspectRatio,
imageCount,
imageSize,
modelNodeNames,
detailReferenceImagesBySkuId,
materialPartAnnotationsBySkuId,
structureReferenceImageMaxCount: AI_RENDER_STRUCTURE_REFERENCE_IMAGE_MAX_COUNT,
getCurrentCameraView,
getController: () => cameraControllerRef.current,
captureCurrentReferenceImage: () => capturePreview?.(WHITE_BACKGROUND_CAPTURE_OPTIONS) ?? null,
captureReferenceAtView: (view, options) =>
captureReferenceAtView(view, {
controller: options?.controller,
restoreAfterCapture: options?.restoreAfterCapture,
}),
buildMaterialPartLocatorReferenceImage,
commitSelectedSkuDetailReference,
startRenderTask,
updateRunningRenderTask,
appendServerRenderRecords,
setMessageForSku,
settleRenderTask,
setMessage,
})
}, [
appendServerRenderRecords,
aspectRatio,
buildMaterialPartLocatorReferenceImage,
canGenerate,
capturePreview,
captureReferenceAtView,
commitSelectedSkuDetailReference,
detailReferenceImagesBySkuId,
generating,
getCurrentCameraView,
imageCount,
imageSize,
materialPartAnnotationsBySkuId,
modelNodeNames,
negativePrompt,
prompt,
selectedSku,
selectedSpu,
setMessage,
setMessageForSku,
settleRenderTask,
startRenderTask,
updateRunningRenderTask,
])
const handleGenerateEightViews = useCallback(async () => {
await runEightViewGeneration({
canGenerate,
generating,
selectedSku,
selectedSpu,
prompt,
negativePrompt,
aspectRatio,
imageSize,
modelNodeNames,
canCapturePreview: Boolean(capturePreview),
getController: () => cameraControllerRef.current,
captureReferenceAtView: (view, options) =>
captureReferenceAtView(view, {
controller: options?.controller,
restoreAfterCapture: options?.restoreAfterCapture,
}),
buildProjectedDetailReferenceFromImage,
startRenderTask,
updateRunningRenderTask,
appendServerRenderRecords,
setMessageForSku,
settleRenderTask,
setMessage,
})
}, [
appendServerRenderRecords,
aspectRatio,
buildProjectedDetailReferenceFromImage,
canGenerate,
capturePreview,
captureReferenceAtView,
generating,
imageSize,
modelNodeNames,
negativePrompt,
prompt,
selectedSku,
selectedSpu,
setMessage,
setMessageForSku,
settleRenderTask,
startRenderTask,
updateRunningRenderTask,
])
const handleRegenerateEightView = useCallback(
async (record: LocalRenderRecord) => {
await runEightViewRegeneration({
canGenerate,
generating,
selectedSku,
selectedSpu,
prompt,
negativePrompt,
aspectRatio,
imageSize,
modelNodeNames,
canCapturePreview: Boolean(capturePreview),
getController: () => cameraControllerRef.current,
record,
captureReferenceAtView: (view, options) =>
captureReferenceAtView(view, {
controller: options?.controller,
restoreAfterCapture: options?.restoreAfterCapture,
}),
buildProjectedDetailReferenceFromImage,
startRenderTask,
updateRunningRenderTask,
appendServerRenderRecords,
setMessageForSku,
settleRenderTask,
setMessage,
})
},
[
appendServerRenderRecords,
aspectRatio,
buildProjectedDetailReferenceFromImage,
canGenerate,
capturePreview,
captureReferenceAtView,
generating,
imageSize,
modelNodeNames,
negativePrompt,
prompt,
selectedSku,
selectedSpu,
setMessage,
setMessageForSku,
settleRenderTask,
startRenderTask,
updateRunningRenderTask,
],
)
const handleOptimizePrompt = useCallback(async () => {
await runPromptOptimization({
prompt,
aspectRatio,
optimizing,
captureCurrentReferenceImage: () => capturePreview?.(WHITE_BACKGROUND_CAPTURE_OPTIONS) ?? null,
setOptimizing,
setOptimExplanation,
setPrompt,
setMessage,
})
}, [aspectRatio, capturePreview, optimizing, prompt])
const handleGenerateScene = useCallback(async () => {
await runSceneGeneration({
canGenerate,
generating,
selectedSku,
selectedSpu,
prompt,
negativePrompt,
aspectRatio,
imageCount,
imageSize,
modelNodeNames,
getCurrentCameraView,
getController: () => cameraControllerRef.current,
captureCurrentReferenceImage: () => capturePreview?.(WHITE_BACKGROUND_CAPTURE_OPTIONS) ?? null,
captureReferenceAtView: (view, options) =>
captureReferenceAtView(view, {
controller: options?.controller,
restoreAfterCapture: options?.restoreAfterCapture,
}),
startRenderTask,
appendServerRenderRecords,
setMessageForSku,
settleRenderTask,
setMessage,
})
}, [
appendServerRenderRecords,
aspectRatio,
canGenerate,
capturePreview,
captureReferenceAtView,
generating,
getCurrentCameraView,
imageCount,
imageSize,
modelNodeNames,
negativePrompt,
prompt,
selectedSku,
selectedSpu,
setMessage,
setMessageForSku,
settleRenderTask,
startRenderTask,
])
const detailSelectionOverlay =
canGenerate && selectedSku?.modelUrl ? (
<WorkbenchReferenceOverlay
detailSelectionMode={detailSelectionMode}
detailReference={selectedSkuDetailReference}
materialPartAnnotationInput={materialPartAnnotationInput}
materialPartAnnotations={selectedSkuMaterialPartAnnotations}
previewDetailSelectionRect={previewDetailSelectionRect}
onMaterialPartAnnotationInputChange={setMaterialPartAnnotationInput}
onStartDetailSelectionMode={startDetailSelectionMode}
onClearDetailSelection={handleClearDetailSelection}
onAddMaterialPartAnnotation={handleAddMaterialPartAnnotation}
onClearMaterialPartAnnotations={handleClearMaterialPartAnnotations}
onRemoveMaterialPartAnnotation={handleRemoveMaterialPartAnnotation}
onCancelDetailSelection={handleExitDetailSelection}
onDetailSelectionPointerDown={handleDetailSelectionPointerDown}
onDetailSelectionPointerMove={handleDetailSelectionPointerMove}
onDetailSelectionPointerUp={handleDetailSelectionPointerUp}
onDetailSelectionPointerCancel={handleDetailSelectionPointerCancel}
onDetailSelectionContextMenu={handleDetailSelectionContextMenu}
/>
) : null
return (
<div className="asset-center-obsidian flex h-full min-h-0 flex-col overflow-hidden bg-[#f8f9fa] text-[#2d3335] lg:flex-row">
<WorkbenchPreviewStage
selectedSkuId={selectedSku?.id ?? null}
selectedSkuModelUrl={selectedSku?.modelUrl || null}
selectedNodeName={selectedNodeName}
selectedNodeNames={selectedNodeNames}
modelNodeNames={modelNodeNames}
materialAssignments={materialAssignments}
stats={stats}
detailSelectionOverlay={detailSelectionOverlay}
showMaterialPanel={Boolean(canWriteConfig && materialPanel && selectedSku)}
materialPanelMode={materialPanel?.mode ?? null}
materialPanelNodeName={materialPanel?.nodeName ?? null}
materialPanelPosition={materialPanelPosition}
materialPanelSubtitle={materialPanelSubtitle}
materialPanelHasMaterialConfig={materialPanelHasMaterialConfig}
materialSubmenuPosition={materialSubmenuPosition}
uvSubmenuPosition={uvSubmenuPosition}
contextMenuGroups={contextMenuGroups}
materialMenuItems={materialMenuItems}
uvEditorForm={uvEditorForm}
uvConfigDirty={uvConfigDirty}
onEngineReady={handleEngineReady}
onCaptureReady={handleCaptureReady}
onModelNodesChange={handlePreviewModelNodesChange}
onNodeSelect={handlePreviewNodeSelect}
onNodeContextMenu={handlePreviewNodeContextMenu}
onContextMenuItemClick={handleContextMenuItemClick}
onContextMenuHover={handleContextMenuHover}
onCloseMaterialPanel={closeMaterialPanel}
onMaterialSelect={handleMaterialSelect}
onPatchUvEditorForm={handlePatchUvEditorForm}
onSelectUvProjectionAxis={handleSelectUvProjectionAxis}
onResetUvEditor={handleResetUvEditor}
onClearUvEditor={handleClearUvEditor}
onKeepUvDraft={handleKeepUvDraft}
/>
<RenderInspector
spus={spus}
selectedSpuId={selectedSpu?.id ?? null}
selectedSkuId={selectedSku?.id ?? null}
uploadingSkuId={uploadingSkuId}
spu={selectedSpu}
sku={selectedSku}
prompt={prompt}
negativePrompt={negativePrompt}
aspectRatio={aspectRatio}
imageCount={imageCount}
generating={generating}
renderTask={selectedRenderTask}
optimizing={optimizing}
optimExplanation={optimExplanation}
message={message}
loading={workbenchLoading}
canWriteConfig={canWriteConfig}
canGenerate={canGenerate}
canDeleteImages={canDeleteImages}
records={selectedSkuRecords}
onSelectSpu={handleSelectSpu}
onCreateSpu={handleCreateSpu}
onRenameSpu={(spuId, name) => updateSpu(spuId, { name })}
onDeleteSpu={handleDeleteSpu}
onSelectSku={handleSelectSku}
onCreateSku={handleCreateSku}
onUpdateSkuById={updateSku}
onDeleteSku={handleDeleteSku}
onUploadSkuModel={handleUploadSkuModel}
onPromptChange={setPrompt}
onNegativePromptChange={setNegativePrompt}
onAspectRatioChange={setAspectRatio}
onImageCountChange={setImageCount}
onCaptureWhiteBackground={handleCaptureWhiteBackground}
onGenerateEightViews={handleGenerateEightViews}
onOptimizePrompt={handleOptimizePrompt}
onGenerateScene={handleGenerateScene}
onRegenerateEightView={handleRegenerateEightView}
onDeleteRecord={handleDeleteRecord}
onRefreshRecords={handleRefreshRecords}
/>
</div>
)
}