ai-chatbot/lib/ai/providers/alibaba-image-input.ts

165 lines
4.5 KiB
TypeScript

import sharp from "sharp";
export type AlibabaImageInputRole =
| "product"
| "model"
| "supplement"
| "layout";
export type PreparedAlibabaImageInput = {
buffer: Buffer;
extension: "png" | "jpg";
transformed: boolean;
transparentProductTrimmed: boolean;
};
function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value));
}
export function selectAlibabaInputImageUrls(
imageUrls: string[],
maxImages = 3,
) {
const normalized = Array.from(
new Set(
imageUrls
.map((url) => String(url || "").trim())
.filter((url) => /^https?:\/\//i.test(url)),
),
);
const limit = Math.max(1, Math.floor(maxImages));
if (normalized.length <= limit) return normalized;
return [...normalized.slice(0, limit - 1), normalized.at(-1)!];
}
async function trimTransparentProduct(
buffer: Buffer,
maxSide: number,
jpegQuality: number,
) {
const trimmed = await sharp(buffer, {
animated: false,
failOn: "none",
})
.rotate()
.trim({
background: { r: 0, g: 0, b: 0, alpha: 0 },
threshold: 8,
})
.toBuffer({ resolveWithObject: true });
const padding = clamp(
Math.round(Math.max(trimmed.info.width, trimmed.info.height) * 0.04),
24,
160,
);
return sharp(trimmed.data, { failOn: "none" })
.flatten({ background: "#ffffff" })
.extend({
top: padding,
bottom: padding,
left: padding,
right: padding,
background: "#ffffff",
})
.resize(maxSide, maxSide, {
fit: "inside",
withoutEnlargement: true,
})
.jpeg({ quality: jpegQuality, mozjpeg: true })
.toBuffer();
}
export async function prepareAlibabaImageInputBuffer(params: {
buffer: Buffer;
contentType?: string | null;
imageUrl?: string | null;
inputRole?: AlibabaImageInputRole | null;
maxInputBytes: number;
maxSide: number;
modelId: string;
}): Promise<PreparedAlibabaImageInput> {
const metadata = await sharp(params.buffer, { failOn: "none" }).metadata();
const format = String(metadata.format || "").toLowerCase();
const contentType = String(params.contentType || "").toLowerCase();
const cleanUrl = String(params.imageUrl || "")
.split("?")[0]
?.toLowerCase();
const isGif =
format === "gif" || contentType.includes("gif") || cleanUrl.endsWith(".gif");
const isTooLarge = params.buffer.length > params.maxInputBytes;
const isTooWide = Number(metadata.width || 0) > params.maxSide;
const isTooTall = Number(metadata.height || 0) > params.maxSide;
const isQwenImage = /qwen-image/i.test(params.modelId);
const shouldTrimTransparentProduct = Boolean(
isQwenImage && params.inputRole === "product" && metadata.hasAlpha,
);
if (shouldTrimTransparentProduct) {
const prepared = await trimTransparentProduct(params.buffer, params.maxSide, 90);
if (prepared.length > params.maxInputBytes) {
const compressed = await sharp(prepared, { failOn: "none" })
.resize(3072, 3072, { fit: "inside", withoutEnlargement: true })
.jpeg({ quality: 82, mozjpeg: true })
.toBuffer();
return {
buffer: compressed,
extension: "jpg",
transformed: true,
transparentProductTrimmed: true,
};
}
return {
buffer: prepared,
extension: "jpg",
transformed: true,
transparentProductTrimmed: true,
};
}
if (!isGif && !isTooLarge && !isTooWide && !isTooTall) {
return {
buffer: params.buffer,
extension: format === "jpeg" || format === "jpg" ? "jpg" : "png",
transformed: false,
transparentProductTrimmed: false,
};
}
const resizeTo = async (side: number, quality?: number) => {
const pipeline = sharp(params.buffer, {
animated: false,
failOn: "none",
})
.rotate()
.resize(side, side, { fit: "inside", withoutEnlargement: true });
return quality
? pipeline.jpeg({ quality, mozjpeg: true }).toBuffer()
: pipeline.png({ compressionLevel: 8 }).toBuffer();
};
let prepared = await resizeTo(params.maxSide);
let extension: "png" | "jpg" = "png";
if (prepared.length > params.maxInputBytes) {
prepared = await resizeTo(params.maxSide, 88);
extension = "jpg";
}
if (prepared.length > params.maxInputBytes) {
prepared = await resizeTo(3072, 82);
extension = "jpg";
}
if (prepared.length > params.maxInputBytes) {
prepared = await resizeTo(2048, 76);
extension = "jpg";
}
return {
buffer: prepared,
extension,
transformed: true,
transparentProductTrimmed: false,
};
}