fix(intake): 修复AI白底图借源正视图丢失
This commit is contained in:
parent
013256dfbd
commit
394d2c2ce6
@ -31,7 +31,9 @@ jest.mock("./NonIntegratedAiWhiteBackgroundPanel", () => ({
|
||||
latestPanelProps = props;
|
||||
const typedProps = props as {
|
||||
onSelectedSourceBarcodeChange?: (barcode: string) => void;
|
||||
onGenerate?: () => Promise<void> | void;
|
||||
onGenerate?: (
|
||||
requestedViewKeys?: AiWhiteBackgroundViewKey[],
|
||||
) => Promise<void> | void;
|
||||
};
|
||||
return (
|
||||
<div data-testid="ai-white-background-panel-stub">
|
||||
@ -44,6 +46,12 @@ jest.mock("./NonIntegratedAiWhiteBackgroundPanel", () => ({
|
||||
<button type="button" onClick={() => void typedProps.onGenerate?.()}>
|
||||
generate-bulk
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void typedProps.onGenerate?.(["front"])}
|
||||
>
|
||||
generate-front
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@ -383,6 +391,91 @@ describe("NonIntegratedAiWhiteBackgroundWorkspace", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("allows generating a missing target view from other available source views", async () => {
|
||||
mockedGenerateSession.mockResolvedValue(createGenerateResponse("front"));
|
||||
|
||||
render(
|
||||
<NonIntegratedAiWhiteBackgroundWorkspace
|
||||
productId="product-1"
|
||||
isReadOnly={false}
|
||||
canUseAiWhiteBackground
|
||||
currentProductPhotoStyleKey="K01"
|
||||
currentProductPhotoStyleLabel="娆惧紡涓€"
|
||||
currentStyleBarcodeEntries={[
|
||||
createBarcodeEntry({
|
||||
skuItemId: "sku-source-1",
|
||||
barcode: "BAR-001",
|
||||
label: "BAR-001 / 鎵规1",
|
||||
secondaryLabel: "鑳℃鏈ㄨ壊",
|
||||
hasOriginalImages: true,
|
||||
aiSourceSlotFiles: createAiSourceSlotFiles({
|
||||
back: createUploadFile("back", { name: "back-source.png" }),
|
||||
left: createUploadFile("left", { name: "left-source.png" }),
|
||||
right: createUploadFile("right", { name: "right-source.png" }),
|
||||
}),
|
||||
availableAiSourceSlotCount: 3,
|
||||
}),
|
||||
createBarcodeEntry({
|
||||
skuItemId: "sku-target-1",
|
||||
barcode: "BAR-002",
|
||||
label: "BAR-002 / 鎵规1",
|
||||
secondaryLabel: "娴呮鏈ㄨ壊",
|
||||
hasOriginalImages: false,
|
||||
aiSourceSlotFiles: createAiSourceSlotFiles({}),
|
||||
availableAiSourceSlotCount: 0,
|
||||
}),
|
||||
]}
|
||||
currentTargetBarcodeEntry={createBarcodeEntry({
|
||||
skuItemId: "sku-target-1",
|
||||
barcode: "BAR-002",
|
||||
label: "BAR-002 / 鎵规1",
|
||||
secondaryLabel: "娴呮鏈ㄨ壊",
|
||||
hasOriginalImages: false,
|
||||
aiSourceSlotFiles: createAiSourceSlotFiles({}),
|
||||
availableAiSourceSlotCount: 0,
|
||||
})}
|
||||
selectedTargetBarcode="BAR-002"
|
||||
displayedSkuItems={[
|
||||
createSkuItem({ barcode: "BAR-001" }),
|
||||
createSkuItem({ barcode: "BAR-002", color: "娴呮鏈ㄨ壊" }),
|
||||
]}
|
||||
skuItems={[
|
||||
createSkuItem({ barcode: "BAR-001" }),
|
||||
createSkuItem({ barcode: "BAR-002", color: "娴呮鏈ㄨ壊" }),
|
||||
]}
|
||||
materialLibraryFinishes={[]}
|
||||
uploadFiles={[]}
|
||||
productMetadata={undefined}
|
||||
onProductSynced={() => {}}
|
||||
onNotify={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "select-source" }));
|
||||
await waitFor(() => {
|
||||
expect(latestPanelProps?.generateDisabledReason).toBeNull();
|
||||
});
|
||||
expect(
|
||||
(latestPanelProps?.sourceFilesByView as Record<string, unknown>).front,
|
||||
).toBeNull();
|
||||
expect(
|
||||
(latestPanelProps?.sourceReadyByView as Record<string, boolean>).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;
|
||||
|
||||
@ -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<AiWhiteBackgroundViewKey, boolean>,
|
||||
[sourceFilesByView],
|
||||
) as Record<AiWhiteBackgroundViewKey, boolean>;
|
||||
},
|
||||
[effectiveSourceBarcode, sourceFilesByView],
|
||||
);
|
||||
const availableSourceViewKeys = useMemo(
|
||||
() =>
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<AiWhiteBackgroundSourceSlot, NonIntegratedUploadFile | null>;
|
||||
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(
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -265,6 +265,7 @@ function buildAiWhiteBackgroundPrompt(
|
||||
targetColor: string | null,
|
||||
materialReferences: AiWhiteBackgroundResolvedMaterialReference[],
|
||||
structureReferenceViewLabels: string[] = [],
|
||||
primaryReferenceViewKey: AiWhiteBackgroundViewKey = viewKey,
|
||||
) {
|
||||
const englishViewLabelByKey: Record<AiWhiteBackgroundViewKey, string> = {
|
||||
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<AiWhiteBackgroundSourceSlot, NonIntegratedUploadFile | null>,
|
||||
) {
|
||||
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<AiWhiteBackgroundSourceSlot, NonIntegratedUploadFile | null>,
|
||||
options: {
|
||||
excludeFileIds?: Set<string>;
|
||||
maxReferences?: number;
|
||||
strategy?: "adjacent" | "nearest";
|
||||
} = {},
|
||||
) {
|
||||
const referenceImages: AiWhiteBackgroundReferenceImage[] = [];
|
||||
const fileIds: string[] = [];
|
||||
const viewLabels: string[] = [];
|
||||
const excludeFileIds = options.excludeFileIds ?? new Set<string>();
|
||||
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<typeof generateAiWhiteBackgroundImage>
|
||||
@ -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] = [
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user