From 394d2c2ce664164d9f8e561f9e850c4cf0e86cf4 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Thu, 18 Jun 2026 13:20:00 +0800 Subject: [PATCH] =?UTF-8?q?fix(intake):=20=E4=BF=AE=E5=A4=8DAI=E7=99=BD?= =?UTF-8?q?=E5=BA=95=E5=9B=BE=E5=80=9F=E6=BA=90=E6=AD=A3=E8=A7=86=E5=9B=BE?= =?UTF-8?q?=E4=B8=A2=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...egratedAiWhiteBackgroundWorkspace.test.tsx | 95 +++++++++++- ...onIntegratedAiWhiteBackgroundWorkspace.tsx | 17 ++- .../styleWorkspaceEntries.test.ts | 72 +++++++++ .../style-assets/styleWorkspaceEntries.ts | 53 ++++--- ...grated-ai-white-background.service.test.ts | 139 +++++++++++++++++- ...-integrated-ai-white-background.service.ts | 125 ++++++++++++---- 6 files changed, 439 insertions(+), 62 deletions(-) diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/ai-white-background/NonIntegratedAiWhiteBackgroundWorkspace.test.tsx b/furniture-3d-system/src/app/intake/non-integrated/workspaces/ai-white-background/NonIntegratedAiWhiteBackgroundWorkspace.test.tsx index 549871da..2296efba 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/workspaces/ai-white-background/NonIntegratedAiWhiteBackgroundWorkspace.test.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/ai-white-background/NonIntegratedAiWhiteBackgroundWorkspace.test.tsx @@ -31,7 +31,9 @@ jest.mock("./NonIntegratedAiWhiteBackgroundPanel", () => ({ latestPanelProps = props; const typedProps = props as { onSelectedSourceBarcodeChange?: (barcode: string) => void; - onGenerate?: () => Promise | void; + onGenerate?: ( + requestedViewKeys?: AiWhiteBackgroundViewKey[], + ) => Promise | void; }; return (
@@ -44,6 +46,12 @@ jest.mock("./NonIntegratedAiWhiteBackgroundPanel", () => ({ +
); }, @@ -383,6 +391,91 @@ describe("NonIntegratedAiWhiteBackgroundWorkspace", () => { ); }); + it("allows generating a missing target view from other available source views", async () => { + mockedGenerateSession.mockResolvedValue(createGenerateResponse("front")); + + render( + {}} + onNotify={() => {}} + />, + ); + + fireEvent.click(screen.getByRole("button", { name: "select-source" })); + await waitFor(() => { + expect(latestPanelProps?.generateDisabledReason).toBeNull(); + }); + expect( + (latestPanelProps?.sourceFilesByView as Record).front, + ).toBeNull(); + expect( + (latestPanelProps?.sourceReadyByView as Record).front, + ).toBe(true); + + fireEvent.click(screen.getByRole("button", { name: "generate-front" })); + + await waitFor(() => { + expect(mockedGenerateSession).toHaveBeenCalledTimes(1); + }); + expect(mockedGenerateSession.mock.calls[0]?.[1]).toEqual( + expect.objectContaining({ + targetBarcode: "BAR-002", + sourceBarcode: "BAR-001", + requestedViewKeys: ["front"], + }), + ); + }); + it("runs bulk generation with limited concurrent view requests", async () => { const pendingRequests: Array<{ viewKey: AiWhiteBackgroundViewKey; diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/ai-white-background/NonIntegratedAiWhiteBackgroundWorkspace.tsx b/furniture-3d-system/src/app/intake/non-integrated/workspaces/ai-white-background/NonIntegratedAiWhiteBackgroundWorkspace.tsx index ba67c578..1a94c27e 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/workspaces/ai-white-background/NonIntegratedAiWhiteBackgroundWorkspace.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/ai-white-background/NonIntegratedAiWhiteBackgroundWorkspace.tsx @@ -356,14 +356,21 @@ export function NonIntegratedAiWhiteBackgroundWorkspace({ [sourceEntry], ); const sourceReadyByView = useMemo( - () => - Object.fromEntries( + () => { + const actualSourceViewCount = AI_WHITE_BACKGROUND_VIEW_KEYS.filter( + (viewKey) => Boolean(sourceFilesByView[viewKey]), + ).length; + const canInferMissingView = + Boolean(effectiveSourceBarcode) && actualSourceViewCount >= 3; + + return Object.fromEntries( AI_WHITE_BACKGROUND_VIEW_KEYS.map((viewKey) => [ viewKey, - Boolean(sourceFilesByView[viewKey]), + Boolean(sourceFilesByView[viewKey]) || canInferMissingView, ]), - ) as Record, - [sourceFilesByView], + ) as Record; + }, + [effectiveSourceBarcode, sourceFilesByView], ); const availableSourceViewKeys = useMemo( () => diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/style-assets/styleWorkspaceEntries.test.ts b/furniture-3d-system/src/app/intake/non-integrated/workspaces/style-assets/styleWorkspaceEntries.test.ts index cdfa8dd3..83a9130a 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/workspaces/style-assets/styleWorkspaceEntries.test.ts +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/style-assets/styleWorkspaceEntries.test.ts @@ -134,4 +134,76 @@ describe("buildProductPhotoStyleEntries", () => { true, ); }); + + it("keeps shared positive source when the latest borrow session is partial", () => { + const entries = buildProductPhotoStyleEntries({ + activeFiles: [ + createProductPhotoFile({ + id: "file-shared-positive", + photoGroup: "nine-view", + photoSlot: "positive", + productPhotoBarcode: "", + }), + createProductPhotoFile({ + id: "file-shared-back", + photoGroup: "nine-view", + photoSlot: "back", + productPhotoBarcode: "", + }), + ], + skuItems: [createSkuItem({ barcode: "SZ024201V01" })], + metadata: { + aiWhiteBackground: { + sessions: [ + { + id: "old-complete-source-session", + styleKey: "K01", + targetBarcode: "SZ024201V01", + sourceBarcode: "SZ024201V01", + targetColor: "", + barcode: "SZ024201V01", + skuBatchNo: 1, + status: "generated", + sourceFileIdsBySlot: { + positive: "file-shared-positive", + }, + generatedFileIdsByView: {}, + createdAt: "2026-06-18T02:00:00.000Z", + updatedAt: "2026-06-18T02:00:00.000Z", + }, + { + id: "latest-partial-borrow-session", + styleKey: "K01", + targetBarcode: "SZ024202V01", + sourceBarcode: "SZ024201V01", + targetColor: "", + barcode: "SZ024202V01", + skuBatchNo: 1, + status: "generated", + sourceFileIdsBySlot: { + back: "file-shared-back", + }, + generatedFileIdsByView: { + back: "generated-back", + }, + createdAt: "2026-06-18T03:28:00.000Z", + updatedAt: "2026-06-18T03:31:00.000Z", + }, + ], + uploadManifests: [], + }, + }, + styleKeys: ["K01"], + }); + + const sourceEntry = entries[0]?.barcodeEntries.find( + (entry) => entry.barcode === "SZ024201V01", + ); + + expect(sourceEntry?.aiSourceSlotFiles.positive?.id).toBe( + "file-shared-positive", + ); + expect(sourceEntry?.aiSourceSlotFiles.back?.id).toBe("file-shared-back"); + expect(sourceEntry?.availableAiSourceSlotCount).toBe(2); + }); }); diff --git a/furniture-3d-system/src/app/intake/non-integrated/workspaces/style-assets/styleWorkspaceEntries.ts b/furniture-3d-system/src/app/intake/non-integrated/workspaces/style-assets/styleWorkspaceEntries.ts index 1fefebed..054c87d8 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/workspaces/style-assets/styleWorkspaceEntries.ts +++ b/furniture-3d-system/src/app/intake/non-integrated/workspaces/style-assets/styleWorkspaceEntries.ts @@ -614,6 +614,10 @@ function buildProductPhotoBarcodeEntries({ normalizedStyleKey, barcode, ); + const effectiveOwnSlotFiles = mergeProductPhotoSlotFiles( + mergeProductPhotoSlotFiles(sharedStyleSlotFiles, scopedSlotFiles), + sessionSourceSlotFiles, + ); const ownScopedSourceCount = countFilledProductPhotoSlots(scopedSlotFiles) + countFilledAiSourceSlots(sessionSourceSlotFiles); @@ -621,7 +625,7 @@ function buildProductPhotoBarcodeEntries({ const hasOriginalImages = ownScopedSourceCount > 0 || hasCoverImage; const effectiveProductPhotoSlotFiles = ownScopedSourceCount > 0 - ? mergeProductPhotoSlotFiles(scopedSlotFiles, sessionSourceSlotFiles) + ? effectiveOwnSlotFiles : hasCoverImage ? sharedStyleSlotFiles : createEmptyProductPhotoSlotFileMap(); @@ -649,12 +653,7 @@ function buildProductPhotoBarcodeEntries({ productPhotoSlotFiles: effectiveProductPhotoSlotFiles, aiSourceSlotFiles: ownScopedSourceCount > 0 - ? buildAiSourceSlotFilesFromProductPhotoSlots( - mergeProductPhotoSlotFiles( - scopedSlotFiles, - sessionSourceSlotFiles, - ), - ) + ? buildAiSourceSlotFilesFromProductPhotoSlots(effectiveOwnSlotFiles) : hasCoverImage ? buildAiSourceSlotFilesFromProductPhotoSlots(sharedStyleSlotFiles) : createEmptyAiSourceSlotFileMap(), @@ -664,12 +663,7 @@ function buildProductPhotoBarcodeEntries({ availableAiSourceSlotCount: ownScopedSourceCount > 0 ? countFilledAiSourceSlots( - buildAiSourceSlotFilesFromProductPhotoSlots( - mergeProductPhotoSlotFiles( - scopedSlotFiles, - sessionSourceSlotFiles, - ), - ), + buildAiSourceSlotFilesFromProductPhotoSlots(effectiveOwnSlotFiles), ) : hasCoverImage ? countFilledAiSourceSlots( @@ -780,7 +774,7 @@ function getLatestSessionSourceSlotFilesByBarcode( styleKey: string, barcode: string, ) { - const latestSession = readNonIntegratedAiWhiteBackgroundMetadata(metadata).sessions + const sourceSessions = readNonIntegratedAiWhiteBackgroundMetadata(metadata).sessions .filter((session) => { const sessionStyleKey = session.styleKey.trim().toUpperCase(); const sessionSourceBarcode = normalizeBarcode( @@ -788,22 +782,25 @@ function getLatestSessionSourceSlotFilesByBarcode( ); return sessionStyleKey === styleKey && sessionSourceBarcode === barcode; }) - .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0]; + .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); - if (!latestSession) { - return createEmptyAiSourceSlotFileMap(); - } + const slotFiles = createEmptyAiSourceSlotFileMap(); - return Object.fromEntries( - AI_WHITE_BACKGROUND_SOURCE_SLOTS.map((slot) => [ - slot, - latestSession.sourceFileIdsBySlot[slot] - ? activeFiles.find( - (file) => file.id === latestSession.sourceFileIdsBySlot[slot], - ) ?? null - : null, - ]), - ) as Record; + sourceSessions.forEach((session) => { + AI_WHITE_BACKGROUND_SOURCE_SLOTS.forEach((slot) => { + if (slotFiles[slot]) { + return; + } + const fileId = session.sourceFileIdsBySlot[slot]; + if (!fileId) { + return; + } + slotFiles[slot] = + activeFiles.find((file) => file.id === fileId) ?? null; + }); + }); + + return slotFiles; } function getLatestGeneratedWhiteBackgroundCountByBarcode( diff --git a/furniture-3d-system/src/server/services/__tests__/non-integrated-ai-white-background.service.test.ts b/furniture-3d-system/src/server/services/__tests__/non-integrated-ai-white-background.service.test.ts index dd73b20d..74868dd3 100644 --- a/furniture-3d-system/src/server/services/__tests__/non-integrated-ai-white-background.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/non-integrated-ai-white-background.service.test.ts @@ -608,7 +608,7 @@ describe("nonIntegratedAiWhiteBackgroundService", () => { ); }); - it("rejects generation when the fixed source views are incomplete", async () => { + it("rejects generation when no source views exist for requested fixed views", async () => { const product = createProduct({ uploadFiles: [], }); @@ -626,7 +626,7 @@ describe("nonIntegratedAiWhiteBackgroundService", () => { ), ).rejects.toMatchObject({ code: ErrorCode.VALIDATION_FAILED, - message: expect.stringContaining("正视图"), + message: expect.any(String), }); await expect( nonIntegratedAiWhiteBackgroundService.generateSession( @@ -640,7 +640,7 @@ describe("nonIntegratedAiWhiteBackgroundService", () => { ), ).rejects.toMatchObject({ code: ErrorCode.VALIDATION_FAILED, - message: expect.stringContaining("后视图"), + message: expect.any(String), }); expect(mockedFetch).not.toHaveBeenCalled(); @@ -1215,6 +1215,139 @@ describe("nonIntegratedAiWhiteBackgroundService", () => { }); }); + it("generates a missing view from nearest available source views", async () => { + mockedGetActiveProviderConfig.mockResolvedValue({ + baseUrl: "https://ai.example.com", + apiKey: "ai-key", + } as never); + mockedFetch.mockImplementation(async (input) => { + const url = typeof input === "string" ? input : String(input); + if ( + url.includes("/api/intake/non-integrated/assets/") || + url.includes("non-integrated-assets/") + ) { + return { + ok: true, + arrayBuffer: async () => + png1x1Buffer.buffer.slice( + png1x1Buffer.byteOffset, + png1x1Buffer.byteOffset + png1x1Buffer.byteLength, + ), + headers: { + get: () => "image/png", + }, + } as never; + } + if (url === "https://ai.example.com/api/external/image/generate") { + return { + ok: true, + json: async () => ({ + data: { + requestId: "ai-request-infer-1", + images: [{ url: "https://cdn.example.com/generated-front.png" }], + }, + }), + } as never; + } + if (url === "https://cdn.example.com/generated-front.png") { + return { + ok: true, + arrayBuffer: async () => generatedImageBuffer, + headers: { + get: () => "image/png", + }, + } as never; + } + + throw new Error(`Unexpected fetch call: ${url}`); + }); + const sourceCategory = createGeneratedFile("session-category", "front").category; + const product = createProduct({ + skuItems: [ + createSkuItem({ + id: "sku-source-1", + barcode: "BAR-001", + color: "walnut brown", + }), + createSkuItem({ + id: "sku-target-1", + barcode: "BAR-002", + color: "light oak", + }), + ], + uploadFiles: [ + { + id: "source-back", + name: "K01-back.png", + category: sourceCategory, + dataUrl: "/api/intake/non-integrated/assets/source-back.png", + mimeType: "image/png", + photoGroup: "back", + productPhotoStyleKey: "K01", + productPhotoBarcode: "BAR-001", + }, + { + id: "source-left", + name: "K01-left.png", + category: sourceCategory, + dataUrl: "/api/intake/non-integrated/assets/source-left.png", + mimeType: "image/png", + photoGroup: "left", + productPhotoStyleKey: "K01", + productPhotoBarcode: "BAR-001", + }, + { + id: "source-right", + name: "K01-right.png", + category: sourceCategory, + dataUrl: "/api/intake/non-integrated/assets/source-right.png", + mimeType: "image/png", + photoGroup: "right", + productPhotoStyleKey: "K01", + productPhotoBarcode: "BAR-001", + }, + ], + }); + mockedGetProductById.mockResolvedValue(product as never); + + const result = await nonIntegratedAiWhiteBackgroundService.generateSession( + "product-1", + { + styleKey: "K01", + targetBarcode: "BAR-002", + sourceBarcode: "BAR-001", + requestedViewKeys: ["front"], + }, + authUser, + ); + + const generateCall = mockedFetch.mock.calls.find( + ([url]) => String(url) === "https://ai.example.com/api/external/image/generate", + ); + const generateRequest = generateCall?.[1] as RequestInit | undefined; + const generateFormData = generateRequest?.body as FormData | undefined; + expect(generateFormData?.getAll("images")).toHaveLength(3); + expect(String(generateFormData?.get("prompt"))).toContain( + "The requested final output is the front view", + ); + expect(String(generateFormData?.get("prompt"))).toContain( + "The primary reference image is the back view", + ); + expect(String(generateFormData?.get("prompt"))).toContain( + "same finish color, wood tone, saturation, brightness", + ); + expect(String(generateFormData?.get("prompt"))).toContain( + "Keep shelf boards, front and back rail edges", + ); + expect(result.session.generatedFileIdsByView).toEqual({ + front: expect.any(String), + }); + expect(result.session.sourceFileIdsBySlot.positive).toBeUndefined(); + expect(result.session.structureReferenceFileIdsByView).toEqual({ + front: ["source-left", "source-right"], + }); + }); + it("rejects generation when the target barcode has no color text", async () => { mockedGetActiveProviderConfig.mockResolvedValue({ baseUrl: "https://ai.example.com", diff --git a/furniture-3d-system/src/server/services/non-integrated-ai-white-background.service.ts b/furniture-3d-system/src/server/services/non-integrated-ai-white-background.service.ts index 737441ca..6a929608 100644 --- a/furniture-3d-system/src/server/services/non-integrated-ai-white-background.service.ts +++ b/furniture-3d-system/src/server/services/non-integrated-ai-white-background.service.ts @@ -265,6 +265,7 @@ function buildAiWhiteBackgroundPrompt( targetColor: string | null, materialReferences: AiWhiteBackgroundResolvedMaterialReference[], structureReferenceViewLabels: string[] = [], + primaryReferenceViewKey: AiWhiteBackgroundViewKey = viewKey, ) { const englishViewLabelByKey: Record = { front: "front view", @@ -293,21 +294,28 @@ function buildAiWhiteBackgroundPrompt( const normalizedStructureReferenceLabels = structureReferenceViewLabels .map((label) => label.trim()) .filter(Boolean); + const primaryReferenceViewLabel = englishViewLabelByKey[primaryReferenceViewKey]; + const targetViewLabel = englishViewLabelByKey[viewKey]; + const hasTargetViewPrimaryReference = primaryReferenceViewKey === viewKey; return [ "Create a realistic studio product render from this furniture product photo.", - `The primary reference image is the ${englishViewLabelByKey[viewKey]}; lock the final camera angle, framing, orientation, and visible side relationships to this primary image.`, + hasTargetViewPrimaryReference + ? `The primary reference image is the ${targetViewLabel}; lock the final camera angle, framing, orientation, and visible side relationships to this primary image.` + : `The requested final output is the ${targetViewLabel}. The primary reference image is the ${primaryReferenceViewLabel}, so use it for the product's structure, proportions, material, and color only; reconstruct the camera angle, framing, orientation, and visible side relationships as the ${targetViewLabel}.`, normalizedStructureReferenceLabels.length > 0 ? `Use the additional structure reference images (${normalizedStructureReferenceLabels.join(", ")}) only to verify product details that are unclear in the primary image, such as shelves, wheels, back panels, side panels, holes, hardware, seams, and thickness. The final output must still be the ${englishViewLabelByKey[viewKey]}, not any auxiliary reference view.` : "", targetColor ? `The target product finish theme is ${targetColor}.` : "Keep the original overall finish family unless a part instruction says otherwise.", + "All generated views in this session must look like the same physical product with the same finish color, wood tone, saturation, brightness, hardware color, wheel size, and shadow style.", + "Keep shelf boards, front and back rail edges, side posts, handles, wheels, seams, holes, thickness, and curved or straight board edges structurally consistent across all views.", "Preserve the furniture structure, silhouette, proportions, hardware, hole details, seams, and camera angle.", "Different part groups must stay separated; do not let one material spill into another part.", ...materialInstructions, "Use a clean white background, centered composition, and product-photography lighting.", - `Keep the current view as ${englishViewLabelByKey[viewKey]}.`, + `Keep the current view as ${targetViewLabel}.`, ].filter(Boolean).join(" "); } @@ -1368,20 +1376,85 @@ function getAiWhiteBackgroundStructureReferenceViewKeys( .filter((item): item is AiWhiteBackgroundViewKey => Boolean(item)); } +function getAiWhiteBackgroundNearestReferenceViewKeys( + viewKey: AiWhiteBackgroundViewKey, +) { + const currentIndex = AI_WHITE_BACKGROUND_VIEW_KEYS.indexOf(viewKey); + if (currentIndex < 0) { + return []; + } + + const total = AI_WHITE_BACKGROUND_VIEW_KEYS.length; + return AI_WHITE_BACKGROUND_VIEW_KEYS.filter((candidate) => candidate !== viewKey) + .map((candidate) => { + const candidateIndex = AI_WHITE_BACKGROUND_VIEW_KEYS.indexOf(candidate); + const distance = Math.abs(candidateIndex - currentIndex); + return { + candidate, + distance: Math.min(distance, total - distance), + }; + }) + .sort((left, right) => left.distance - right.distance) + .map((item) => item.candidate); +} + +function findAiWhiteBackgroundPrimaryReferenceFile( + viewKey: AiWhiteBackgroundViewKey, + sourceFileMap: Record, +) { + const sourceSlot = AI_WHITE_BACKGROUND_VIEW_KEY_TO_SOURCE_SLOT[viewKey]; + const directSourceFile = sourceFileMap[sourceSlot]; + if (directSourceFile) { + return { + file: directSourceFile, + viewKey, + isDirect: true, + }; + } + + for (const referenceViewKey of getAiWhiteBackgroundNearestReferenceViewKeys( + viewKey, + )) { + const referenceSlot = AI_WHITE_BACKGROUND_VIEW_KEY_TO_SOURCE_SLOT[referenceViewKey]; + const referenceFile = sourceFileMap[referenceSlot]; + if (referenceFile) { + return { + file: referenceFile, + viewKey: referenceViewKey, + isDirect: false, + }; + } + } + + return null; +} + async function buildAiWhiteBackgroundStructureReferencesForView( viewKey: AiWhiteBackgroundViewKey, sourceFileMap: Record, + options: { + excludeFileIds?: Set; + maxReferences?: number; + strategy?: "adjacent" | "nearest"; + } = {}, ) { const referenceImages: AiWhiteBackgroundReferenceImage[] = []; const fileIds: string[] = []; const viewLabels: string[] = []; + const excludeFileIds = options.excludeFileIds ?? new Set(); + const maxReferences = options.maxReferences ?? 2; + const referenceViewKeys = + options.strategy === "nearest" + ? getAiWhiteBackgroundNearestReferenceViewKeys(viewKey) + : getAiWhiteBackgroundStructureReferenceViewKeys(viewKey); - for (const referenceViewKey of getAiWhiteBackgroundStructureReferenceViewKeys( - viewKey, - )) { + for (const referenceViewKey of referenceViewKeys) { + if (referenceImages.length >= maxReferences) { + break; + } const sourceSlot = AI_WHITE_BACKGROUND_VIEW_KEY_TO_SOURCE_SLOT[referenceViewKey]; const sourceFile = sourceFileMap[sourceSlot]; - if (!sourceFile) { + if (!sourceFile || excludeFileIds.has(sourceFile.id)) { continue; } @@ -1960,6 +2033,9 @@ export class NonIntegratedAiWhiteBackgroundService { requestedPartGroupingSource ?? "user_confirmed"; const now = new Date().toISOString(); const sourceFileMap = buildSourceFileMap(product, styleKey, sourceBarcode); + const sourceFileCount = AI_WHITE_BACKGROUND_SOURCE_SLOTS.filter((slot) => + Boolean(sourceFileMap[slot]), + ).length; const effectiveRequestedViewKeys = requestedViewKeys.length > 0 ? requestedViewKeys @@ -1967,26 +2043,12 @@ export class NonIntegratedAiWhiteBackgroundService { const slot = AI_WHITE_BACKGROUND_VIEW_KEY_TO_SOURCE_SLOT[viewKey]; return Boolean(sourceFileMap[slot]); }); - if (effectiveRequestedViewKeys.length === 0) { + if (effectiveRequestedViewKeys.length === 0 || sourceFileCount === 0) { throw new AppError( ErrorCode.VALIDATION_FAILED, "当前款式还没有可用于生成白底图的九视图源图,请先上传至少 1 个角度。", ); } - const missingViews = effectiveRequestedViewKeys.filter((viewKey) => { - const slot = AI_WHITE_BACKGROUND_VIEW_KEY_TO_SOURCE_SLOT[viewKey]; - return !sourceFileMap[slot]; - }); - - if (missingViews.length > 0) { - throw new AppError( - ErrorCode.VALIDATION_FAILED, - `还缺少这些九视图源图:${missingViews - .map((viewKey) => AI_WHITE_BACKGROUND_VIEW_LABELS[viewKey]) - .join("、")}`, - ); - } - const existingSession = (input.sessionId ? getAiWhiteBackgroundSessionById(product.metadata, input.sessionId) @@ -2031,21 +2093,29 @@ export class NonIntegratedAiWhiteBackgroundService { for (const viewKey of effectiveRequestedViewKeys) { const sourceSlot = AI_WHITE_BACKGROUND_VIEW_KEY_TO_SOURCE_SLOT[viewKey]; - const sourceFile = sourceFileMap[sourceSlot]; - if (!sourceFile) { + const primaryReference = findAiWhiteBackgroundPrimaryReferenceFile( + viewKey, + sourceFileMap, + ); + if (!primaryReference) { throw new AppError( ErrorCode.FILE_NOT_FOUND, `没有找到 ${AI_WHITE_BACKGROUND_SOURCE_SLOT_LABELS[sourceSlot]} 源图。`, ); } - const sourceBuffer = await readAssetBufferByUrl(sourceFile.dataUrl); + const sourceBuffer = await readAssetBufferByUrl(primaryReference.file.dataUrl); const normalizedReferenceBuffer = await normalizeReferenceImageForAi(sourceBuffer); const structureReferences = await buildAiWhiteBackgroundStructureReferencesForView( viewKey, sourceFileMap, + { + excludeFileIds: new Set([primaryReference.file.id]), + maxReferences: primaryReference.isDirect ? 2 : 4, + strategy: primaryReference.isDirect ? "adjacent" : "nearest", + }, ); let generatedResult: Awaited< ReturnType @@ -2063,6 +2133,7 @@ export class NonIntegratedAiWhiteBackgroundService { targetColor, materialReferences, structureReferences.viewLabels, + primaryReference.viewKey, ), negativePrompt: buildAiWhiteBackgroundNegativePrompt(targetColor), }); @@ -2109,7 +2180,11 @@ export class NonIntegratedAiWhiteBackgroundService { generatedFile.contentType, ); - nextSourceFileIdsBySlot[sourceSlot] = sourceFile.id; + if (primaryReference.isDirect) { + nextSourceFileIdsBySlot[sourceSlot] = primaryReference.file.id; + } else { + delete nextSourceFileIdsBySlot[sourceSlot]; + } nextGeneratedFileIdsByView[viewKey] = fileId; if (structureReferences.fileIds.length > 0) { nextStructureReferenceFileIdsByView[viewKey] = [