import "server-only"; import { createHash } from "node:crypto"; import sharp from "sharp"; import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service"; import { uploadFile } from "@/lib/storage/service"; import { cacheImageBytes } from "@/lib/storage/image-byte-cache"; import { inferClosestReplacementAspectRatio } from "@/app/(chat)/api/chat/_lib/detail-replacement"; import { locateLayoutReplaceProduct, locateLayoutReplaceTemplateText } from "./layout-replace-verifier"; import { layoutReplaceProductCrop, layoutReplaceProductAspectRatio } from "./layout-replace-product-crop"; type PreparedImage = { buffer: Buffer; url: string; width: number; height: number }; const staged = new Map }>(); async function readImage(url: string) { const original = await fetchImageBufferFromUrl(url); // Decode both actual files before submitting a paid generation; neither an // advisory HEAD failure nor an HTML error page may remove one of the roles. const { data: buffer, info } = await sharp(original, { failOn: "error" }) .rotate().png().toBuffer({ resolveWithObject: true }); if (info.width < 16 || info.height < 16) throw new Error("图片尺寸过小,请使用清晰的完整图片"); return { buffer, width: info.width, height: info.height }; } async function stageImage(image: Omit, role: "template" | "product"): Promise { const { buffer } = image; const digest = createHash("sha256").update(buffer).digest("hex"); const key = `${role}:${digest}`; let entry = staged.get(key); if (!entry || entry.expires < Date.now()) { if (staged.size >= 64) staged.delete(staged.keys().next().value!); const promise = (async () => { const uploaded = await uploadFile(`layout-${role}-${digest.slice(0, 20)}.png`, Uint8Array.from(buffer).buffer); await cacheImageBytes(`source::${uploaded.url}`, { buffer, contentType: "image/png" }); return uploaded.url; })(); entry = { expires: Date.now() + 10 * 60_000, url: promise }; staged.set(key, entry); promise.catch(() => { if (staged.get(key) === entry) staged.delete(key); }); } return { ...image, url: await entry.url }; } export async function prepareLayoutReplaceInputs(sourceUrl: string, referenceUrl: string, options?: { checkText?: boolean }) { if (sourceUrl === referenceUrl) throw new Error("新商品图和目标模版不能是同一张图片"); const [originalSource, originalTemplate] = await Promise.all([ readImage(sourceUrl), readImage(referenceUrl), ]); if (originalSource.buffer.equals(originalTemplate.buffer)) throw new Error("新商品图和目标模版不能是同一张图片"); // A full room photo is a strong competing composition. Give the editor the // complete product crop, while the independent verifier sees the original // source so an incorrect crop cannot silently redefine the product identity. const [{ box, identity }, textBoundingBoxes] = await Promise.all([ locateLayoutReplaceProduct(originalSource.buffer, originalTemplate.buffer), options?.checkText === false ? Promise.resolve([]) : locateLayoutReplaceTemplateText(originalTemplate.buffer), ]); const crop = layoutReplaceProductCrop(box, originalSource.width, originalSource.height); const productBuffer = await sharp(originalSource.buffer).extract(crop).png().toBuffer(); const [source, template] = await Promise.all([ stageImage({ buffer: productBuffer, width: crop.width, height: crop.height }, "product"), stageImage(originalTemplate, "template"), ]); return { source, template, textBoundingBoxes, sourceIdentity: identity, sourceOriginalBuffer: originalSource.buffer, productAspectRatio: layoutReplaceProductAspectRatio(box, originalSource.width, originalSource.height), referenceGeometry: { width: template.width, height: template.height, aspectRatio: inferClosestReplacementAspectRatio(template.width, template.height), } }; }