165 lines
4.5 KiB
TypeScript
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,
|
|
};
|
|
}
|