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.
975 lines
31 KiB
TypeScript
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>
|
|
)
|
|
}
|