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 fbcf0bea..7125a66c 100644 --- a/furniture-3d-system/src/app/ai-render-workbench/AiRenderWorkbenchClient.tsx +++ b/furniture-3d-system/src/app/ai-render-workbench/AiRenderWorkbenchClient.tsx @@ -1,8 +1,9 @@ 'use client' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import NextImage from 'next/image' import * as THREE from 'three' -import { AlertCircle, Camera, Sparkles } from 'lucide-react' +import { AlertCircle, Camera, Crop, Sparkles, Trash2 } from 'lucide-react' import { AssetWorkbenchEmptyBackdrop } from '@/components/platform/AssetWorkbenchEmptyBackdrop' import { ModelPreviewPanel, type ModelPreviewCapture } from '@/components/platform/ModelPreviewPanel' import { @@ -14,8 +15,10 @@ import { } from '@/components/ui/ContextMenu' import { createAssistedAssemblyAiRender, + createAiRenderWorkbenchDetailReference, createAiRenderWorkbenchSku, createAiRenderWorkbenchSpu, + deleteAiRenderWorkbenchDetailReference, deleteAiRenderWorkbenchSku, deleteAiRenderWorkbenchSpu, deleteAssistedAssemblyAiRender, @@ -44,6 +47,7 @@ import type { AiRenderTaskState, AiRenderSku, AiRenderSpu, + AiRenderTemporaryReferenceImage, AiRenderUvEditorFormState, LocalRenderRecord, } from './types' @@ -77,16 +81,15 @@ const CURVE_DETAIL_ENHANCEMENT_NEGATIVE_PROMPT = const WHITE_BACKGROUND_CLEAN_BACKGROUND_NEGATIVE_PROMPT = 'dirty background, textured background, room scene, non-white backdrop, background artifacts, material swatches, floor texture, marble floor, floor reflection, gray shadow blobs' const WHITE_BACKGROUND_COMPOSITION_LOCK_NEGATIVE_PROMPT = - 'recentered product, reframed product, changed crop, changed scale, straightened perspective, standardized catalog angle' + 'recentered product, reframed product, changed crop, changed scale, straightened perspective, standardized catalog angle, mirrored orientation, flipped left-right, flipped front-back visibility, swapped visible sides' const WHITE_BACKGROUND_MATERIAL_POLISH_NEGATIVE_PROMPT = 'milky haze, washed-out texture, blown highlights, white glare, gray cast, plastic finish, mirror gloss, invented wood grain, dramatic striping, synthetic veneer pattern, lightened wood tone, overly uniform surface, warm orange cast, red wood cast, yellow wood cast, oversaturated brown finish, showroom recolor, honey wood tint, peach wood tint, golden filter, orange glaze, heavy ambient warmth' const WHITE_BACKGROUND_MICRO_STRUCTURE_NEGATIVE_PROMPT = - 'filled-in recesses, painted cutout shadows, fake cavity graphics, missing inner depth, flattened relief depth, shallow recesses, sealed openings, softened cavity edges, erased join gaps, erased seam lines, lost thickness breaks, airbrushed panel lines' + 'filled-in recesses, painted cutout shadows, fake cavity graphics, missing inner depth, flattened relief depth, shallow recesses, sealed openings, softened cavity edges, erased join gaps, erased seam lines, lost thickness breaks, airbrushed panel lines, completed partial opening into full symmetric opening, standardized opening symbol, printed dark circle, symbolic cutout graphic' const AI_RENDER_NEGATIVE_PROMPT_MAX_LENGTH = 800 const AI_RENDER_PROMPT_MAX_LENGTH = 3200 const AI_RENDER_EIGHT_VIEW_STRUCTURE_REFERENCE_OFFSET = 1 -const WHITE_BACKGROUND_STRUCTURE_REFERENCE_YAW_OFFSETS = [] as const const AI_RENDER_TASK_PROGRESS_CAP = 88 const AI_RENDER_TASK_PROGRESS_STAGES = [ { progress: 12, stage: '正在抓取当前 3D 参考图' }, @@ -108,10 +111,11 @@ const AI_RENDER_DETAIL_REFERENCE_CAPTURE_PADDING = 1.01 const AI_RENDER_REFERENCE_CAPTURE_PADDING = 1.05 const AI_RENDER_WORKBENCH_DEFAULT_VIEW_PADDING = 1.05 const AI_RENDER_DEFAULT_IMAGE_SIZE = '2K' -const AI_RENDER_DETAIL_REFERENCE_MIN_WIDTH_RATIO = 0.24 -const AI_RENDER_DETAIL_REFERENCE_MIN_HEIGHT_RATIO = 0.2 -const AI_RENDER_DETAIL_REFERENCE_MAX_WIDTH_RATIO = 0.7 -const AI_RENDER_DETAIL_REFERENCE_MAX_HEIGHT_RATIO = 0.56 +const AI_RENDER_DETAIL_SELECTION_MIN_SIZE_RATIO = 0.03 +const AI_RENDER_DETAIL_SELECTION_CONTEXT_SCALE = 1.9 +const AI_RENDER_DETAIL_SELECTION_CONTEXT_MIN_RATIO = 0.22 +const AI_RENDER_DETAIL_SELECTION_CONTEXT_MAX_RATIO = 0.44 +const WHITE_BACKGROUND_MANUAL_DETAIL_REFERENCE_LABEL = 'same-view cropped detail reference' function normalizeWorkbenchImageSize(value: unknown): string { if (typeof value !== 'string') { @@ -229,6 +233,20 @@ type AiRenderImageCropRect = { height: number } +type AiRenderNormalizedSelectionRect = { + x: number + y: number + width: number + height: number +} + +type AiRenderDetailSelectionDraft = { + startX: number + startY: number + currentX: number + currentY: number +} + type ProjectBoundsToImageRectOptions = { paddingX?: number paddingY?: number @@ -238,14 +256,133 @@ type ProjectBoundsToImageRectOptions = { maxHeightRatio?: number | null } -type WhiteBackgroundStructureBandConfig = { - promptLabel: string - displayLabel: string - rectRatios: { - xStart: number - xEnd: number - yStart: number - yEnd: number +function clampNormalizedRatio(value: number): number { + if (!Number.isFinite(value)) return 0 + return Math.min(Math.max(value, 0), 1) +} + +function normalizeTemporaryReferenceImage(value: unknown): AiRenderTemporaryReferenceImage | undefined { + if (!isRecord(value)) return undefined + + const url = typeof value.url === 'string' ? value.url.trim() : '' + const storageKey = typeof value.storageKey === 'string' ? value.storageKey.trim() : '' + if (!url || !storageKey) { + return undefined + } + + return { + url, + storageKey, + expiresAt: + typeof value.expiresAt === 'string' || value.expiresAt === null + ? value.expiresAt + : null, + temporary: value.temporary === true, + } +} + +function normalizeDetailReferenceImages( + value: unknown, +): Record | undefined { + if (!isRecord(value)) return undefined + + const entries = Object.entries(value).reduce>( + (result, [skuId, image]) => { + const normalizedImage = normalizeTemporaryReferenceImage(image) + if (skuId.trim() && normalizedImage) { + result[skuId] = normalizedImage + } + return result + }, + {}, + ) + + return Object.keys(entries).length > 0 ? entries : undefined +} + +function buildNormalizedSelectionRect( + startX: number, + startY: number, + endX: number, + endY: number, +): AiRenderNormalizedSelectionRect | null { + const minX = clampNormalizedRatio(Math.min(startX, endX)) + const maxX = clampNormalizedRatio(Math.max(startX, endX)) + const minY = clampNormalizedRatio(Math.min(startY, endY)) + const maxY = clampNormalizedRatio(Math.max(startY, endY)) + const width = maxX - minX + const height = maxY - minY + + if ( + width < AI_RENDER_DETAIL_SELECTION_MIN_SIZE_RATIO || + height < AI_RENDER_DETAIL_SELECTION_MIN_SIZE_RATIO + ) { + return null + } + + return { + x: minX, + y: minY, + width, + height, + } +} + +function expandNormalizedSelectionRect( + rect: AiRenderNormalizedSelectionRect, +): AiRenderNormalizedSelectionRect { + const span = Math.max(rect.width, rect.height) + const expandDimension = (value: number): number => + Math.max( + value, + Math.min( + Math.max( + value * AI_RENDER_DETAIL_SELECTION_CONTEXT_SCALE, + span * 1.45, + AI_RENDER_DETAIL_SELECTION_CONTEXT_MIN_RATIO, + ), + AI_RENDER_DETAIL_SELECTION_CONTEXT_MAX_RATIO, + ), + ) + + const width = Math.min(1, expandDimension(rect.width)) + const height = Math.min(1, expandDimension(rect.height)) + const centerX = rect.x + rect.width / 2 + const centerY = rect.y + rect.height / 2 + + return { + x: clampNormalizedRatio(Math.min(Math.max(centerX - width / 2, 0), 1 - width)), + y: clampNormalizedRatio(Math.min(Math.max(centerY - height / 2, 0), 1 - height)), + width, + height, + } +} + +function buildCropRectFromNormalizedSelection( + imageWidth: number, + imageHeight: number, + rect: AiRenderNormalizedSelectionRect | null | undefined, +): AiRenderImageCropRect | null { + if (!rect || imageWidth <= 0 || imageHeight <= 0) { + return null + } + + const minX = Math.round(clampNormalizedRatio(rect.x) * imageWidth) + const minY = Math.round(clampNormalizedRatio(rect.y) * imageHeight) + const maxX = Math.round(clampNormalizedRatio(rect.x + rect.width) * imageWidth) + const maxY = Math.round(clampNormalizedRatio(rect.y + rect.height) * imageHeight) + const width = Math.max(1, Math.min(imageWidth, maxX) - Math.max(0, minX)) + const height = Math.max(1, Math.min(imageHeight, maxY) - Math.max(0, minY)) + + if (width <= 1 || height <= 1) { + return null + } + + return { + x: Math.max(0, Math.min(imageWidth - 1, minX)), + y: Math.max(0, Math.min(imageHeight - 1, minY)), + width, + height, } } @@ -422,97 +559,6 @@ function projectBoundsToImageRect( } } -function cropImageRectFromBaseRect( - baseRect: AiRenderImageCropRect, - rectRatios: WhiteBackgroundStructureBandConfig['rectRatios'], - imageWidth: number, - imageHeight: number, -): AiRenderImageCropRect | null { - if (baseRect.width <= 1 || baseRect.height <= 1 || imageWidth <= 0 || imageHeight <= 0) { - return null - } - - const clamp = (value: number, min: number, max: number): number => - Math.min(Math.max(value, min), max) - const enforceRange = ( - start: number, - end: number, - minBound: number, - maxBound: number, - minLength: number, - maxLength: number, - ): [number, number] => { - let nextStart = start - let nextEnd = end - let length = nextEnd - nextStart - const center = (nextStart + nextEnd) / 2 - - if (length < minLength) { - nextStart = center - minLength / 2 - nextEnd = center + minLength / 2 - length = minLength - } - - if (length > maxLength) { - nextStart = center - maxLength / 2 - nextEnd = center + maxLength / 2 - } - - if (nextStart < minBound) { - nextEnd += minBound - nextStart - nextStart = minBound - } - if (nextEnd > maxBound) { - nextStart -= nextEnd - maxBound - nextEnd = maxBound - } - - nextStart = clamp(nextStart, minBound, maxBound) - nextEnd = clamp(nextEnd, minBound, maxBound) - return [nextStart, nextEnd] - } - - const startX = baseRect.x + baseRect.width * rectRatios.xStart - const endX = baseRect.x + baseRect.width * rectRatios.xEnd - const startY = baseRect.y + baseRect.height * rectRatios.yStart - const endY = baseRect.y + baseRect.height * rectRatios.yEnd - - let minX = Math.min(startX, endX) - let maxX = Math.max(startX, endX) - let minY = Math.min(startY, endY) - let maxY = Math.max(startY, endY) - - ;[minX, maxX] = enforceRange( - minX, - maxX, - 0, - imageWidth, - imageWidth * AI_RENDER_DETAIL_REFERENCE_MIN_WIDTH_RATIO, - imageWidth * AI_RENDER_DETAIL_REFERENCE_MAX_WIDTH_RATIO, - ) - ;[minY, maxY] = enforceRange( - minY, - maxY, - 0, - imageHeight, - imageHeight * AI_RENDER_DETAIL_REFERENCE_MIN_HEIGHT_RATIO, - imageHeight * AI_RENDER_DETAIL_REFERENCE_MAX_HEIGHT_RATIO, - ) - - const width = Math.max(1, Math.round(maxX - minX)) - const height = Math.max(1, Math.round(maxY - minY)) - if (width <= 1 || height <= 1) { - return null - } - - return { - x: Math.max(0, Math.round(minX)), - y: Math.max(0, Math.round(minY)), - width: Math.min(width, imageWidth), - height: Math.min(height, imageHeight), - } -} - function buildLocalStructureReferenceBounds( box: THREE.Box3, baseView: CameraViewSnapshot, @@ -640,35 +686,6 @@ function buildProjectionCameraFromSnapshot( return projectionCamera } -function getCameraElevationDeg(view: CameraViewSnapshot): number { - const target = new THREE.Vector3(...view.target) - const offset = new THREE.Vector3(...view.position).sub(target) - const horizontalLength = Math.sqrt(offset.x * offset.x + offset.z * offset.z) - return THREE.MathUtils.radToDeg(Math.atan2(offset.y, horizontalLength || 1)) -} - -function buildWhiteBackgroundStructureReferences( - baseView: CameraViewSnapshot, -): AiRenderStructureReferenceConfig[] { - const target = new THREE.Vector3(...baseView.target) - const offset = new THREE.Vector3(...baseView.position).sub(target) - const normalizedOffset = offset.lengthSq() > 1e-6 ? offset.clone().normalize() : null - if (!normalizedOffset || Math.abs(normalizedOffset.y) >= 0.82) { - return [] - } - - const minElevationDeg = Math.max(0, Math.min(getCameraElevationDeg(baseView), 24)) - // White-background renders should preserve the primary view exactly; rotated - // auxiliary references are intentionally disabled here because they can pull - // the model into a flatter, more frontal catalog angle. - const enabledReferenceAngles = WHITE_BACKGROUND_STRUCTURE_REFERENCE_YAW_OFFSETS.slice(0, 0) - return enabledReferenceAngles.map((angleDeg) => ({ - promptLabel: angleDeg < 0 ? 'left detail reference' : 'right detail reference', - displayLabel: angleDeg < 0 ? '左侧结构参考' : '右侧结构参考', - cameraView: buildRotatedCameraView(baseView, angleDeg, minElevationDeg), - })) -} - function buildLocalStructureReference( controller: CameraController, baseView: CameraViewSnapshot, @@ -686,56 +703,6 @@ function buildLocalStructureReference( } } -function buildWhiteBackgroundStructureBandConfigs( - baseView: CameraViewSnapshot, -): WhiteBackgroundStructureBandConfig[] { - const configs: WhiteBackgroundStructureBandConfig[] = [ - { - promptLabel: 'front structure detail band', - displayLabel: '正面结构参考带', - rectRatios: { - xStart: 0.14, - xEnd: 0.86, - yStart: 0.28, - yEnd: 0.68, - }, - }, - ] - - const target = new THREE.Vector3(...baseView.target) - const offset = new THREE.Vector3(...baseView.position).sub(target) - const horizontalLength = Math.sqrt(offset.x * offset.x + offset.z * offset.z) - if (horizontalLength <= 1e-6) { - return configs - } - - const sideVisibility = Math.abs(offset.x) / horizontalLength - if (sideVisibility < 0.16) { - return configs - } - - configs.push({ - promptLabel: offset.x >= 0 ? 'right-side thickness detail band' : 'left-side thickness detail band', - displayLabel: offset.x >= 0 ? '右侧厚度参考带' : '左侧厚度参考带', - rectRatios: - offset.x >= 0 - ? { - xStart: 0.6, - xEnd: 0.96, - yStart: 0.12, - yEnd: 0.88, - } - : { - xStart: 0.04, - xEnd: 0.4, - yStart: 0.12, - yEnd: 0.88, - }, - }) - - return configs -} - type StoredWorkbenchState = { spus: AiRenderSpu[] selectedSpuId: string | null @@ -748,7 +715,9 @@ type StoredWorkbenchState = { imageCount: number } -type StoredWorkbenchUiState = Omit +type StoredWorkbenchUiState = Omit & { + detailReferenceImagesBySkuId?: Record +} interface AiRenderWorkbenchClientProps { finishes: MaterialDTO[] @@ -1045,6 +1014,7 @@ function readStoredWorkbenchUiState(): StoredWorkbenchUiState | null { typeof parsed.imageCount === 'number' && Number.isFinite(parsed.imageCount) ? parsed.imageCount : 1, + detailReferenceImagesBySkuId: normalizeDetailReferenceImages(parsed.detailReferenceImagesBySkuId), } } catch { return null @@ -1224,6 +1194,10 @@ function inferWhiteBackgroundViewConstraint( } } +function isLocalDetailReferenceLabel(label: string): boolean { + return /detail|verification|cropped|same-view/i.test(label) +} + function buildWhiteBackgroundPrompt(params: { spu: AiRenderSpu | null sku: AiRenderSku @@ -1244,7 +1218,7 @@ function buildWhiteBackgroundPrompt(params: { const viewConstraint = inferWhiteBackgroundViewConstraint(params.cameraView) const structureReferenceLabels = uniqueNodeNames(params.structureReferenceLabels ?? []) const hasLocalDetailReference = structureReferenceLabels.some((label) => - /detail|verification|band/i.test(label), + isLocalDetailReferenceLabel(label), ) return [ @@ -1254,6 +1228,7 @@ function buildWhiteBackgroundPrompt(params: { viewConstraint?.detailLine ?? '', 'Treat the current 3D product render as the absolute product baseline. Improve only background, lighting, edge clarity, and presentation polish. Do not reinterpret the product design or shift the finish away from the reference color direction.', 'Preserve the exact product placement inside the canvas from the primary reference image, including footprint size, diagonal direction, left-right balance, top-bottom balance, and whitespace distribution. Do not recenter, reframe, zoom, rotate, or straighten the product into a more standard catalog composition.', + 'Preserve the exact handedness from the primary reference image. Do not mirror the product, flip left-right, swap front-back visibility, or convert an oblique same-side view into the opposite side.', 'Use restrained e-commerce white-background studio lighting with clean edge definition and soft controlled contrast. Keep the product surface clear and dimensional, but do not add a milky veil, floating bloom, broad glossy hotspot, or washed-out white sheen across large flat areas.', 'Match the reference finish color first, including hue, darkness, saturation, and warm-cool balance. Do not brighten it into a prettier showroom tone, gray it out, or shift it toward a different material color direction.', 'If the primary 3D reference and any material texture reference disagree, obey the primary 3D reference for final visible color, darkness, and contrast. Material references are secondary and must never pull the final image toward a lighter, cleaner, or redder showroom finish than the 3D reference shows.', @@ -1266,7 +1241,10 @@ function buildWhiteBackgroundPrompt(params: { 'Keep the background as a seamless pure white sweep. Any contact shadow must stay tight to the product footprint and fade quickly right under the product. Do not create a visible floor plane, marble-like ground texture, broad cloudy shadow fields, or reflection patches under the product.', 'The primary reference image is the only source for global camera angle, perspective, framing, and visible side relationships. Do not let any secondary detail reference flatten, standardize, or rotate the product into a more frontal catalog view.', hasLocalDetailReference - ? 'Use any local detail verification reference only to preserve the true shape, depth, and placement of small structural features such as openings, recesses, cavities, grooves, seam gaps, thickness breaks, and layered overlaps. Treat it as a same-view cropped detail check only. Do not exaggerate their contrast, redraw them as symbols, or let them override the overall natural material appearance.' + ? 'The same-view cropped detail reference is only a local zoom from the same camera frame as the primary image. Use it to verify the small structure in that selected area while keeping the overall view, global proportion, placement, and perspective fully governed by the primary reference image.' + : '', + hasLocalDetailReference + ? 'Treat the cropped detail reference as local clarification only. Do not enlarge, center, complete, deepen, or stylize a local feature just because it is shown larger in the crop. Express that structure naturally at the scale already established by the primary view.' : '', structureReferenceLabels.length > 0 ? `Use the additional structure reference images (${structureReferenceLabels.join(', ')}) only to verify details that are unclear or partially occluded in the primary view, such as recesses, join gaps, rails, side panels, shelves, legs, seams, holes, slots, thickness changes, layered edges, and shadowed cavities. The final output must still match the primary reference view exactly and must not rotate toward the auxiliary references.` @@ -1304,17 +1282,21 @@ function buildEightViewPrompt(params: { const aspectRatio = params.aspectRatio.trim() const structureReferenceLabels = uniqueNodeNames(params.structureReferenceLabels ?? []) const hasLocalDetailReference = structureReferenceLabels.some((label) => - /detail|verification|band/i.test(label), + isLocalDetailReferenceLabel(label), ) return [ 'Create a standard white-background product catalog image for this furniture product view.', `This image is the ${params.viewName} in an eight-view product set. Keep the reference camera angle, orientation, and visible side relationships.`, + 'Preserve the exact handedness of this view. Do not mirror left-right, swap the visible side order, or rotate this view into a cleaner but different catalog angle.', 'Match the primary 3D reference render for final visible color, darkness, saturation, and seam contrast. Do not beautify the finish into a lighter or cleaner showroom wood tone than the reference.', 'If the product includes recessed, cut-through, layered, grooved, hollow, stepped, or relief details, preserve them as real three-dimensional structure with visible depth, thickness, and openings where present. Do not replace them with flat dark marks or painted symbols.', 'Keep join gaps, seam lines, groove shadows, panel breaks, openings, thickness steps, and hardware edges readable from this view without over-darkening them.', hasLocalDetailReference - ? 'Use any local detail verification reference only to preserve the true shape, depth, and placement of small structural features such as openings, recesses, cavities, grooves, seam gaps, thickness breaks, and layered overlaps. Do not exaggerate contrast or convert those details into graphic marks.' + ? 'The same-view cropped detail reference is only a local zoom from this exact view. Use it to verify the small structure in that selected area while keeping the full-view angle, proportion, and composition governed by the main reference.' + : '', + hasLocalDetailReference + ? 'Treat the cropped detail reference as local clarification only. Do not enlarge, center, complete, deepen, or stylize a local feature just because it is shown larger in the crop.' : '', structureReferenceLabels.length > 0 ? `Use the additional structure reference images (${structureReferenceLabels.join(', ')}) only to verify details that are unclear in this view, such as recesses, join gaps, back rails, side panels, shelves, legs, seams, holes, slots, thickness changes, and layered edges. The final output must still be the ${params.viewName}, not the auxiliary views.` @@ -1512,6 +1494,7 @@ export default function AiRenderWorkbenchClient({ finishes, initialAccess }: AiR const engineRef = useRef(null) const cameraControllerRef = useRef(null) const modelRootRef = useRef(null) + const detailSelectionPointerIdRef = useRef(null) const previousSelectedSkuIdRef = useRef(null) const activeSelectedSkuIdRef = useRef(null) const runningSkuIdsRef = useRef>(new Set()) @@ -1527,6 +1510,11 @@ export default function AiRenderWorkbenchClient({ finishes, initialAccess }: AiR ), ) const [imageCount, setImageCount] = useState(storedUiState?.imageCount ?? legacyStoredWorkbenchState?.imageCount ?? 1) + const [detailReferenceImagesBySkuId, setDetailReferenceImagesBySkuId] = useState< + Record + >(() => storedUiState?.detailReferenceImagesBySkuId ?? {}) + const [detailSelectionMode, setDetailSelectionMode] = useState(false) + const [detailSelectionDraft, setDetailSelectionDraft] = useState(null) const [records, setRecords] = useState([]) const [message, setMessage] = useState(null) const [renderTasksBySkuId, setRenderTasksBySkuId] = useState>({}) @@ -1557,6 +1545,10 @@ export default function AiRenderWorkbenchClient({ finishes, initialAccess }: AiR () => buildSkuMaterialAssignments(selectedSku, modelNodeNames), [modelNodeNames, selectedSku], ) + const selectedSkuDetailReference = useMemo( + () => (selectedSku ? detailReferenceImagesBySkuId[selectedSku.id] ?? null : null), + [detailReferenceImagesBySkuId, selectedSku], + ) const selectedSkuRecords = useMemo(() => { if (!selectedSku) return [] return records.filter((record) => record.skuId === selectedSku.id) @@ -1767,10 +1759,12 @@ export default function AiRenderWorkbenchClient({ finishes, initialAccess }: AiR aspectRatio, imageSize: normalizeWorkbenchImageSize(imageSize), imageCount, + detailReferenceImagesBySkuId, } window.localStorage.setItem(AI_RENDER_WORKBENCH_UI_STORAGE_KEY, JSON.stringify(snapshot)) }, [ aspectRatio, + detailReferenceImagesBySkuId, imageCount, imageSize, negativePrompt, @@ -1795,9 +1789,18 @@ export default function AiRenderWorkbenchClient({ finishes, initialAccess }: AiR setNegativePrompt(DEFAULT_NEGATIVE_PROMPT) setOptimExplanation('') setMessage(null) + setDetailSelectionMode(false) + setDetailSelectionDraft(null) } }, [selectedSku?.id]) + useEffect(() => { + if (!detailSelectionMode) { + setDetailSelectionDraft(null) + detailSelectionPointerIdRef.current = null + } + }, [detailSelectionMode]) + useEffect(() => { if (!selectedSku?.id) { return @@ -2380,7 +2383,7 @@ export default function AiRenderWorkbenchClient({ finishes, initialAccess }: AiR referenceImageDataUrl: string, ): Promise => { return new Promise((resolve) => { - const element = new Image() + const element = new window.Image() element.onload = () => resolve(element) element.onerror = () => resolve(null) element.src = referenceImageDataUrl @@ -2416,6 +2419,21 @@ export default function AiRenderWorkbenchClient({ finishes, initialAccess }: AiR return canvas.toDataURL('image/png') }, [readReferenceImageFromDataUrl]) + const convertReferenceImageToDataUrl = useCallback(async ( + referenceImageUrl: string, + ): Promise => { + const response = await fetch(referenceImageUrl) + if (!response.ok) return null + const blob = await response.blob() + + return new Promise((resolve) => { + const reader = new FileReader() + reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : null) + reader.onerror = () => resolve(null) + reader.readAsDataURL(blob) + }) + }, []) + const buildProjectedCropRectFromImage = useCallback(async ( referenceImageDataUrl: string, fitBounds: THREE.Box3 | null | undefined, @@ -2458,6 +2476,240 @@ export default function AiRenderWorkbenchClient({ finishes, initialAccess }: AiR 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 startDetailSelectionMode = useCallback(() => { + if (!selectedSku?.id || !selectedSku.modelUrl) return + setDetailSelectionDraft(null) + setDetailSelectionMode(true) + setMessage('请在当前 3D 视图上拖出一个特写框,我会把它保存成这个 SKU 的细节参考图。') + }, [selectedSku]) + + const handleClearDetailSelection = useCallback(async () => { + setDetailSelectionMode(false) + setDetailSelectionDraft(null) + try { + if (selectedSkuDetailReference?.storageKey) { + await deleteAiRenderWorkbenchDetailReference({ + storageKey: selectedSkuDetailReference.storageKey, + workbenchSkuId: selectedSku?.id ?? null, + }) + } + commitSelectedSkuDetailReference(null) + setMessage('当前 SKU 的细节参考图已清除。') + } catch (error) { + setMessage(error instanceof Error ? error.message : '细节参考图清除失败') + } + }, [commitSelectedSkuDetailReference, selectedSku?.id, selectedSkuDetailReference?.storageKey]) + + const handleDetailSelectionPointerDown = useCallback( + (event: React.PointerEvent) => { + if (!detailSelectionMode || !selectedSku?.id) return + + const bounds = event.currentTarget.getBoundingClientRect() + if (bounds.width <= 0 || bounds.height <= 0) return + + const startX = clampNormalizedRatio((event.clientX - bounds.left) / bounds.width) + const startY = clampNormalizedRatio((event.clientY - bounds.top) / bounds.height) + + detailSelectionPointerIdRef.current = event.pointerId + event.currentTarget.setPointerCapture(event.pointerId) + event.preventDefault() + event.stopPropagation() + setDetailSelectionDraft({ + startX, + startY, + currentX: startX, + currentY: startY, + }) + }, + [detailSelectionMode, selectedSku?.id], + ) + + const handleDetailSelectionPointerMove = useCallback( + (event: React.PointerEvent) => { + if (!detailSelectionDraft) return + if (detailSelectionPointerIdRef.current !== null && event.pointerId !== detailSelectionPointerIdRef.current) { + return + } + + const bounds = event.currentTarget.getBoundingClientRect() + if (bounds.width <= 0 || bounds.height <= 0) return + + const currentX = clampNormalizedRatio((event.clientX - bounds.left) / bounds.width) + const currentY = clampNormalizedRatio((event.clientY - bounds.top) / bounds.height) + + event.preventDefault() + event.stopPropagation() + setDetailSelectionDraft((current) => + current + ? { + ...current, + currentX, + currentY, + } + : current, + ) + }, + [detailSelectionDraft], + ) + + const finishDetailSelection = useCallback(async ( + finalPoint?: { + x: number + y: number + }, + ) => { + const draft = detailSelectionDraft + ? { + ...detailSelectionDraft, + currentX: finalPoint ? finalPoint.x : detailSelectionDraft.currentX, + currentY: finalPoint ? finalPoint.y : detailSelectionDraft.currentY, + } + : null + const taskSku = selectedSku + const previousDetailReference = selectedSkuDetailReference + + setDetailSelectionDraft(null) + detailSelectionPointerIdRef.current = null + setDetailSelectionMode(false) + + if (!draft || !taskSku?.id || !taskSku.modelUrl) return + + const nextSelection = buildNormalizedSelectionRect( + draft.startX, + draft.startY, + draft.currentX, + draft.currentY, + ) + const expandedSelection = nextSelection + ? expandNormalizedSelectionRect(nextSelection) + : null + + if (!nextSelection) { + setMessage('特写框太小了,重新拖一个更明确的区域吧。') + return + } + + // Manual detail references should crop from the exact viewport frame the user selected. + const referenceImageDataUrl = capturePreview?.(WHITE_BACKGROUND_CAPTURE_OPTIONS) ?? null + if (!referenceImageDataUrl) { + setMessage('当前 3D 预览还没准备好,等模型稳定后再框一次。') + return + } + + setMessage('正在保存当前 SKU 的细节参考图…') + try { + const referenceImage = await readReferenceImageFromDataUrl(referenceImageDataUrl) + const detailCropRect = referenceImage + ? buildCropRectFromNormalizedSelection( + referenceImage.naturalWidth, + referenceImage.naturalHeight, + expandedSelection, + ) + : null + const croppedReferenceImageDataUrl = await cropReferenceImageToDataUrl( + referenceImageDataUrl, + detailCropRect, + ) + if (!croppedReferenceImageDataUrl) { + setMessage('细节参考图裁切失败,请重新框选一次。') + return + } + + const savedDetailReference = await createAiRenderWorkbenchDetailReference({ + workbenchSkuId: taskSku.id, + referenceImageDataUrl: croppedReferenceImageDataUrl, + }) + commitSelectedSkuDetailReference(savedDetailReference) + setMessage('细节参考图已保存,后面白底图会优先带这张局部参考。') + + if ( + previousDetailReference?.storageKey && + previousDetailReference.storageKey !== savedDetailReference.storageKey + ) { + void deleteAiRenderWorkbenchDetailReference({ + storageKey: previousDetailReference.storageKey, + workbenchSkuId: taskSku.id, + }).catch(() => undefined) + } + } catch (error) { + setMessage(error instanceof Error ? error.message : '细节参考图保存失败') + } + }, [ + capturePreview, + commitSelectedSkuDetailReference, + cropReferenceImageToDataUrl, + detailSelectionDraft, + readReferenceImageFromDataUrl, + selectedSku, + selectedSkuDetailReference, + ]) + + const handleDetailSelectionPointerUp = useCallback( + async (event: React.PointerEvent) => { + if (detailSelectionPointerIdRef.current !== null && event.pointerId !== detailSelectionPointerIdRef.current) { + return + } + const bounds = event.currentTarget.getBoundingClientRect() + const finalPoint = + bounds.width > 0 && bounds.height > 0 + ? { + x: clampNormalizedRatio((event.clientX - bounds.left) / bounds.width), + y: clampNormalizedRatio((event.clientY - bounds.top) / bounds.height), + } + : undefined + event.preventDefault() + event.stopPropagation() + try { + event.currentTarget.releasePointerCapture(event.pointerId) + } catch { + // ignore capture release failures + } + await finishDetailSelection(finalPoint) + }, + [finishDetailSelection], + ) + + const handleDetailSelectionPointerCancel = useCallback(() => { + detailSelectionPointerIdRef.current = null + setDetailSelectionDraft(null) + setDetailSelectionMode(false) + setMessage('已取消本次特写框选。') + }, []) + + const previewDetailSelectionRect = useMemo(() => { + if (detailSelectionDraft) { + return buildNormalizedSelectionRect( + detailSelectionDraft.startX, + detailSelectionDraft.startY, + detailSelectionDraft.currentX, + detailSelectionDraft.currentY, + ) + } + return null + }, [detailSelectionDraft]) + const handleCaptureWhiteBackground = useCallback(async () => { if (!canGenerate || generating) return const taskSku = selectedSku @@ -2468,8 +2720,6 @@ export default function AiRenderWorkbenchClient({ finishes, initialAccess }: AiR return } const cameraView = getCurrentCameraView() - const referenceCaptureView = - cameraView && controller ? createReferenceCaptureView(controller, cameraView) : null const dataUrl = cameraView && controller && capturePreview ? await captureReferenceAtView(cameraView, { @@ -2487,6 +2737,7 @@ export default function AiRenderWorkbenchClient({ finishes, initialAccess }: AiR const taskImageSize = imageSize const taskNegativePrompt = negativePrompt const taskNodeNames = [...modelNodeNames] + const taskDetailReference = detailReferenceImagesBySkuId[taskSku.id] ?? null const viewConstraint = inferWhiteBackgroundViewConstraint(cameraView) const task = startRenderTask({ mode: 'white-background', @@ -2502,91 +2753,15 @@ export default function AiRenderWorkbenchClient({ finishes, initialAccess }: AiR try { const structureReferenceLabels: string[] = [] const structureReferenceImageDataUrls: string[] = [] - if (controller) { - const baseView = controller.saveView() - const structureReferenceConfigs = buildWhiteBackgroundStructureReferences(baseView) - const localStructureReference = buildLocalStructureReference(controller, baseView) - if (localStructureReference?.fitBounds) { - updateRunningRenderTask(task.id, 24, `正在补充结构参考:${localStructureReference.displayLabel}`) - const localStructureReferenceImageDataUrl = await buildProjectedDetailReferenceFromImage( - dataUrl, - localStructureReference.fitBounds, - referenceCaptureView ?? baseView, - ) - if (localStructureReferenceImageDataUrl) { - structureReferenceLabels.push(localStructureReference.promptLabel) - structureReferenceImageDataUrls.push(localStructureReferenceImageDataUrl) - } - } - try { - for (let index = 0; index < structureReferenceConfigs.length; index += 1) { - const item = structureReferenceConfigs[index]! - updateRunningRenderTask( - task.id, - 18 + Math.round(((index + 1) / (structureReferenceConfigs.length + 1)) * 14), - `正在补充结构参考:${item.displayLabel}`, - ) - const structureReferenceImageDataUrl = await captureReferenceAtView(item.cameraView, { - controller, - capture: capturePreview, - fitBounds: item.fitBounds, - }) - if (structureReferenceImageDataUrl) { - structureReferenceLabels.push(item.promptLabel) - structureReferenceImageDataUrls.push(structureReferenceImageDataUrl) - } - } - } finally { - await controller.restoreView(baseView, { transition: 'instant', duration: 0 }).catch(() => undefined) - } - } - if (controller) { - structureReferenceLabels.length = 0 - structureReferenceImageDataUrls.length = 0 - - const baseView = controller.saveView() - const whiteBackgroundStructureBands = buildWhiteBackgroundStructureBandConfigs(baseView) - const projectedProductRect = await buildProjectedCropRectFromImage( - dataUrl, - controller.getBounds(), - referenceCaptureView ?? baseView, - { - paddingX: 1.04, - paddingY: 1.06, - minWidthRatio: null, - minHeightRatio: null, - maxWidthRatio: null, - maxHeightRatio: null, - }, - ) - const referenceImage = projectedProductRect - ? await readReferenceImageFromDataUrl(dataUrl) - : null - - if (projectedProductRect && referenceImage) { - for (let bandIndex = 0; bandIndex < whiteBackgroundStructureBands.length; bandIndex += 1) { - const band = whiteBackgroundStructureBands[bandIndex]! - updateRunningRenderTask( - task.id, - 24 + Math.round(((bandIndex + 1) / (whiteBackgroundStructureBands.length + 1)) * 10), - `正在补充结构参考:${band.displayLabel}`, - ) - const cropRect = cropImageRectFromBaseRect( - projectedProductRect, - band.rectRatios, - referenceImage.naturalWidth, - referenceImage.naturalHeight, - ) - const structureReferenceImageDataUrl = await cropReferenceImageToDataUrl( - dataUrl, - cropRect, - ) - if (structureReferenceImageDataUrl) { - structureReferenceLabels.push(band.promptLabel) - structureReferenceImageDataUrls.push(structureReferenceImageDataUrl) - } - } + if (taskDetailReference?.url) { + updateRunningRenderTask(task.id, 28, '正在提取手动特写参考') + const structureReferenceImageDataUrl = await convertReferenceImageToDataUrl(taskDetailReference.url) + if (!structureReferenceImageDataUrl) { + commitSelectedSkuDetailReference(null) + throw new Error('当前 SKU 的手动特写参考已失效,请重新框选后再生成白底图。') } + structureReferenceLabels.push(WHITE_BACKGROUND_MANUAL_DETAIL_REFERENCE_LABEL) + structureReferenceImageDataUrls.push(structureReferenceImageDataUrl) } const lockedPrompt = buildWhiteBackgroundPrompt({ spu: taskSpu, @@ -2650,12 +2825,12 @@ export default function AiRenderWorkbenchClient({ finishes, initialAccess }: AiR }, [ appendServerRenderRecords, aspectRatio, - buildProjectedCropRectFromImage, - buildProjectedDetailReferenceFromImage, captureReferenceAtView, capturePreview, canGenerate, - cropReferenceImageToDataUrl, + commitSelectedSkuDetailReference, + convertReferenceImageToDataUrl, + detailReferenceImagesBySkuId, generating, imageCount, imageSize, @@ -2663,7 +2838,6 @@ export default function AiRenderWorkbenchClient({ finishes, initialAccess }: AiR modelNodeNames, negativePrompt, prompt, - readReferenceImageFromDataUrl, settleRenderTask, setMessageForSku, selectedSku, @@ -3200,6 +3374,88 @@ export default function AiRenderWorkbenchClient({ finishes, initialAccess }: AiR } }, [selectedSku]) + const detailSelectionOverlay = + canGenerate && selectedSku?.modelUrl ? ( +
+
+ + {selectedSkuDetailReference ? ( + + ) : null} +
+ + {selectedSkuDetailReference ? ( +
+
+ 已保存特写参考 + + {selectedSkuDetailReference.expiresAt + ? `至 ${new Date(selectedSkuDetailReference.expiresAt).toLocaleDateString('zh-CN', { + month: 'numeric', + day: 'numeric', + })}` + : '临时'} + +
+
+ +
+
+ ) : null} + + {detailSelectionMode ? ( +
+
+
+ 拖拽框住你要重点锁定的局部细节 +
+ {previewDetailSelectionRect ? ( +
+
+ 特写参考 +
+
+ ) : null} +
+ ) : null} +
+ ) : null + return (
@@ -3324,6 +3580,7 @@ export default function AiRenderWorkbenchClient({ finishes, initialAccess }: AiR {stats.spuCount} SPU / {stats.configuredSkuCount}/{stats.skuCount} SKU 已配置
} + overlayCenter={detailSelectionOverlay} overlayBottomCenter={
{ + const body = await request.json().catch(() => null) + const parsed = createDetailReferenceSchema.safeParse(body) + if (!parsed.success) { + return jsonError( + ErrorCode.VALIDATION_FAILED, + parsed.error.issues[0]?.message ?? '特写参考图参数不正确', + 400, + ctx.requestId, + ) + } + + try { + const image = await assistedAssemblyAiRenderService.createWorkbenchDetailReference(parsed.data, ctx.user) + return jsonSuccess({ image }, 201, ctx.requestId) + } catch (error) { + if (error instanceof AppError) { + return jsonError(error.code, error.message, error.statusCode, ctx.requestId) + } + throw error + } + }, + { requireAuth: true }, + ) +} + +export async function DELETE(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + const body = await request.json().catch(() => null) + const parsed = deleteDetailReferenceSchema.safeParse(body) + if (!parsed.success) { + return jsonError( + ErrorCode.VALIDATION_FAILED, + parsed.error.issues[0]?.message ?? '特写参考图删除参数不正确', + 400, + ctx.requestId, + ) + } + + try { + await assistedAssemblyAiRenderService.deleteWorkbenchDetailReference(parsed.data, ctx.user) + return jsonSuccess({ ok: true }, 200, ctx.requestId) + } catch (error) { + if (error instanceof AppError) { + return jsonError(error.code, error.message, error.statusCode, ctx.requestId) + } + throw error + } + }, + { requireAuth: true }, + ) +} diff --git a/furniture-3d-system/src/lib/open-product/platform-api.ts b/furniture-3d-system/src/lib/open-product/platform-api.ts index 5e9ede8f..eadbd240 100644 --- a/furniture-3d-system/src/lib/open-product/platform-api.ts +++ b/furniture-3d-system/src/lib/open-product/platform-api.ts @@ -942,6 +942,13 @@ export interface CreateAssistedAssemblyAiRenderPayload { assetSnapshot: unknown } +export interface AiRenderWorkbenchTemporaryImageDTO { + url: string + storageKey: string + expiresAt: string | null + temporary: boolean +} + export async function listAssistedAssemblyAiRenders(limit = 24): Promise { const searchParams = new URLSearchParams({ limit: String(limit) }) const data = await requestPlatform<{ renders: AssistedAssemblyAiRenderDTO[] }>( @@ -954,6 +961,30 @@ export async function fetchAiRenderWorkbench(): Promise { return requestPlatform('/api/ai-render-workbench') } +export async function createAiRenderWorkbenchDetailReference(input: { + workbenchSkuId: string + referenceImageDataUrl: string +}): Promise { + const data = await requestPlatform<{ image: AiRenderWorkbenchTemporaryImageDTO }>( + '/api/ai-render-workbench/detail-reference', + { + method: 'POST', + body: JSON.stringify(input), + }, + ) + return data.image +} + +export async function deleteAiRenderWorkbenchDetailReference(input: { + storageKey: string + workbenchSkuId?: string | null +}): Promise { + await requestPlatform<{ ok: boolean }>('/api/ai-render-workbench/detail-reference', { + method: 'DELETE', + body: JSON.stringify(input), + }) +} + export async function createAiRenderWorkbenchSpu(input?: { name?: string | null description?: string | null diff --git a/furniture-3d-system/src/server/services/assisted-assembly-ai-render.service.ts b/furniture-3d-system/src/server/services/assisted-assembly-ai-render.service.ts index 53d5acb7..c98573cc 100644 --- a/furniture-3d-system/src/server/services/assisted-assembly-ai-render.service.ts +++ b/furniture-3d-system/src/server/services/assisted-assembly-ai-render.service.ts @@ -1,5 +1,6 @@ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' import path from 'node:path' +import { readdir, stat } from 'node:fs/promises' import type { Prisma } from '@prisma/client' import { AssistedAssemblyAiRenderStatus } from '@prisma/client' import { nanoid } from 'nanoid' @@ -44,6 +45,7 @@ const WORKBENCH_RENDER_CLEANUP_MIN_INTERVAL_MS = 5 * 60 * 1000 const CLEAN_BACKGROUND_WORKBENCH_MODES = new Set(['white-background', 'eight-view']) let lastWorkbenchRenderCleanupAt = 0 +let lastWorkbenchDetailReferenceCleanupAt = 0 interface MaterialTextureEntry { materialId: string @@ -1010,7 +1012,7 @@ function getImageExtensionFromUrl(value: string): string | null { function buildTemporaryRenderStorageKey(params: { createdBy: string - kind: 'references' | 'results' + kind: 'references' | 'results' | 'detail-references' extension: string index?: number }): string { @@ -1022,6 +1024,19 @@ function getTemporaryRenderExpiresAt(): string { return new Date(Date.now() + WORKBENCH_RENDER_FILE_RETENTION_MS).toISOString() } +function isWorkbenchDetailReferenceStorageKey(key: string): boolean { + const localStoragePath = stripLocalWorkbenchRenderStorageKey(key) + const normalized = (localStoragePath ?? key).trim().replace(/^\/+/, '') + return normalized.includes('/detail-references/') || + normalized.startsWith(`${WORKBENCH_RENDER_STORAGE_PREFIX}/`) && normalized.includes('/detail-references/') +} + +function isWorkbenchTemporaryStorageKeyOwnedByUser(storageKey: string, createdBy: string): boolean { + const localStoragePath = stripLocalWorkbenchRenderStorageKey(storageKey) + const normalized = (localStoragePath ?? storageKey).trim().replace(/^\/+/, '') + return normalized.startsWith(`${WORKBENCH_RENDER_STORAGE_PREFIX}/${createdBy}/`) +} + function resolveLocalWorkbenchRenderAbsolutePath(storagePath: string): string { const normalizedStoragePath = storagePath.trim().replace(/^\/+/, '') if (!isTemporaryWorkbenchRenderKey(normalizedStoragePath)) { @@ -1055,6 +1070,59 @@ async function writeLocalWorkbenchRenderFile(params: { } } +async function cleanupExpiredLocalWorkbenchDetailReferenceFiles(): Promise { + if (!shouldUseLocalWorkbenchRenderStorage()) { + return + } + + const now = Date.now() + if (now - lastWorkbenchDetailReferenceCleanupAt < WORKBENCH_RENDER_CLEANUP_MIN_INTERVAL_MS) { + return + } + lastWorkbenchDetailReferenceCleanupAt = now + + const rootDirectory = path.resolve(LOCAL_UPLOADS_ROOT, WORKBENCH_RENDER_STORAGE_PREFIX) + const cutoffTime = now - WORKBENCH_RENDER_FILE_RETENTION_MS + + const walk = async (directory: string): Promise => { + const entries = await readdir(directory, { withFileTypes: true }).catch((error: NodeJS.ErrnoException) => { + if (error?.code === 'ENOENT') { + return [] + } + throw error + }) + + for (const entry of entries) { + const absolutePath = path.join(directory, entry.name) + if (entry.isDirectory()) { + await walk(absolutePath) + continue + } + + if (!absolutePath.includes(`${path.sep}detail-references${path.sep}`)) { + continue + } + + const fileStat = await stat(absolutePath).catch(() => null) + if (!fileStat || fileStat.mtimeMs >= cutoffTime) { + continue + } + + await rm(absolutePath, { force: true }).catch(() => undefined) + } + } + + await walk(rootDirectory) +} + +async function cleanupExpiredLocalWorkbenchDetailReferenceFilesSafely(): Promise { + try { + await cleanupExpiredLocalWorkbenchDetailReferenceFiles() + } catch (error) { + console.warn('[AiRenderWorkbench] cleanup expired detail reference files failed', error) + } +} + async function readLocalWorkbenchRenderFile(storageKey: string): Promise<{ buffer: Buffer contentType: string @@ -1761,6 +1829,46 @@ async function uploadTemporaryWorkbenchReferenceImage(params: { ) } +async function persistTemporaryWorkbenchDetailReferenceImage(params: { + buffer: Buffer + contentType: string + extension: string + createdBy: string +}): Promise<{ + url: string + storageKey: string + expiresAt: string + temporary: true +}> { + const normalizedImage = await normalizeWorkbenchResultOutputSize({ + buffer: params.buffer, + extension: params.extension, + contentType: params.contentType, + }) + const storagePath = buildTemporaryRenderStorageKey({ + createdBy: params.createdBy, + kind: 'detail-references', + extension: normalizedImage.extension, + }) + const upload = shouldUseLocalWorkbenchRenderStorage() + ? await writeLocalWorkbenchRenderFile({ + buffer: normalizedImage.buffer, + storagePath, + }) + : await uploadBufferToObjectStorageWithMetadata( + normalizedImage.buffer, + storagePath, + normalizedImage.contentType, + ) + + return { + url: upload.url, + storageKey: upload.storageKey, + expiresAt: getTemporaryRenderExpiresAt(), + temporary: true, + } +} + function readRenderImageStorageKey(image: AssistedAssemblyAiRenderImageDTO): string | null { return ( trimToNull(image.storageKey) ?? @@ -2094,6 +2202,54 @@ export class AssistedAssemblyAiRenderService { await prisma.assistedAssemblyAiRender.delete({ where: { id: record.id } }) } + async createWorkbenchDetailReference( + input: { + workbenchSkuId: string + referenceImageDataUrl: string + }, + user: AuthUser, + ): Promise<{ + url: string + storageKey: string + expiresAt: string + temporary: true + }> { + const createdBy = normalizeRequiredText(user.id, '用户 ID') + await cleanupExpiredWorkbenchRenderFilesSafely() + await cleanupExpiredLocalWorkbenchDetailReferenceFilesSafely() + await aiRenderWorkbenchService.getSkuForRender(input.workbenchSkuId, user) + const referenceImage = decodeReferenceImageDataUrl(input.referenceImageDataUrl) + + return persistTemporaryWorkbenchDetailReferenceImage({ + buffer: referenceImage.buffer, + contentType: referenceImage.contentType, + extension: referenceImage.extension, + createdBy, + }) + } + + async deleteWorkbenchDetailReference( + input: { + storageKey: string + workbenchSkuId?: string | null + }, + user: AuthUser, + ): Promise { + const createdBy = normalizeRequiredText(user.id, '用户 ID') + const storageKey = normalizeRequiredText(input.storageKey, '特写参考图存储键') + if (!isWorkbenchDetailReferenceStorageKey(storageKey)) { + throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '特写参考图存储键不正确') + } + if (!isWorkbenchTemporaryStorageKeyOwnedByUser(storageKey, createdBy)) { + throw new AppError(ErrorCode.FILE_NOT_FOUND, '没有找到这张特写参考图') + } + if (trimToNull(input.workbenchSkuId)) { + await aiRenderWorkbenchService.getSkuForRender(input.workbenchSkuId!, user) + } + + await deleteWorkbenchRenderStorageKey(storageKey) + } + async create(input: CreateAssistedAssemblyAiRenderInput, user: AuthUser): Promise { const startedAt = Date.now() const createdBy = normalizeRequiredText(user.id, '用户 ID')