ai-chatbot/lib/canvas/layout-replace-inputs.ts

69 lines
3.9 KiB
TypeScript

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<string, { expires: number; url: Promise<string> }>();
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<PreparedImage, "url">, role: "template" | "product"): Promise<PreparedImage> {
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),
} };
}