gongxiangp2/lib/brand-knowledge-preview.ts

156 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import sharp from 'sharp'
import { sliceImage } from '@/lib/image-processor'
import { configureSharpRuntime } from '@/lib/sharp-runtime'
import { withImageProcessingSlot } from '@/lib/image-processing-limiter'
import {
BRAND_KNOWLEDGE_MAX_PREVIEW_IMAGE_BYTES,
BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_BYTES,
BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_COUNT,
} from '@/lib/brand-knowledge-file-types'
configureSharpRuntime()
export const BRAND_KNOWLEDGE_PREVIEW_MAX_WIDTH = 12_000
export const BRAND_KNOWLEDGE_PREVIEW_MAX_HEIGHT = 90_000
export const BRAND_KNOWLEDGE_PREVIEW_MAX_PIXELS = 60_000_000
export const BRAND_KNOWLEDGE_PREVIEW_SLICE_HEIGHT = 1_500
export const BRAND_KNOWLEDGE_PREVIEW_SLICE_MAX_KB = 500
export const BRAND_KNOWLEDGE_PREVIEW_OUTPUT_WIDTH = 1_600
const FORMAT_DETAILS = {
png: { extension: 'png', contentType: 'image/png' },
jpeg: { extension: 'jpg', contentType: 'image/jpeg' },
webp: { extension: 'webp', contentType: 'image/webp' },
} as const
type SupportedPreviewFormat = keyof typeof FORMAT_DETAILS
function normalizeContentType(value?: string | null) {
return String(value || '').split(';', 1)[0].trim().toLowerCase()
}
export function assertBrandKnowledgePreviewMetadata(
metadata: Pick<sharp.Metadata, 'format' | 'width' | 'height' | 'pages'>,
declaredContentType?: string | null,
) {
const format = metadata.format as SupportedPreviewFormat | undefined
const details = format ? FORMAT_DETAILS[format] : undefined
if (!details) throw new Error('预览图只支持 PNG、JPEG 或 WebP')
const width = metadata.width || 0
const height = metadata.height || 0
if (width < 1 || height < 1) throw new Error('无法获取预览图尺寸')
if (width > BRAND_KNOWLEDGE_PREVIEW_MAX_WIDTH || height > BRAND_KNOWLEDGE_PREVIEW_MAX_HEIGHT) {
throw new Error(`预览图尺寸过大,宽度需不超过 ${BRAND_KNOWLEDGE_PREVIEW_MAX_WIDTH}px,高度需不超过 ${BRAND_KNOWLEDGE_PREVIEW_MAX_HEIGHT}px`)
}
if (width * height > BRAND_KNOWLEDGE_PREVIEW_MAX_PIXELS) {
throw new Error('预览图像素总量过大,需不超过 6000 万像素')
}
if (Number(metadata.pages || 1) > 1) throw new Error('预览图不支持动画或多页图片')
const declared = normalizeContentType(declaredContentType)
if (declared && declared !== details.contentType) throw new Error('预览图内容与声明格式不一致')
return { ...details, width, height }
}
export async function prepareBrandKnowledgePreview(buffer: Buffer, declaredContentType?: string | null) {
let metadata: sharp.Metadata
try {
metadata = await sharp(buffer, {
animated: true,
failOn: 'error',
limitInputPixels: BRAND_KNOWLEDGE_PREVIEW_MAX_PIXELS,
sequentialRead: true,
}).metadata()
} catch (error) {
if (error instanceof Error && /pixel limit/i.test(error.message)) {
throw new Error('预览图像素总量过大,需不超过 6000 万像素')
}
throw new Error('预览图无法解码,请上传完整的 PNG、JPEG 或 WebP 图片')
}
const source = assertBrandKnowledgePreviewMetadata(metadata, declaredContentType)
let slices: Buffer[]
try {
slices = await sliceImage(
buffer,
BRAND_KNOWLEDGE_PREVIEW_SLICE_HEIGHT,
BRAND_KNOWLEDGE_PREVIEW_SLICE_MAX_KB,
BRAND_KNOWLEDGE_PREVIEW_OUTPUT_WIDTH,
{
maxInputWidth: BRAND_KNOWLEDGE_PREVIEW_MAX_WIDTH,
maxInputHeight: BRAND_KNOWLEDGE_PREVIEW_MAX_HEIGHT,
maxInputPixels: BRAND_KNOWLEDGE_PREVIEW_MAX_PIXELS,
},
)
} catch {
throw new Error('预览图处理失败,请确认图片完整且未超过尺寸限制')
}
if (slices.length === 0) throw new Error('预览图处理后没有可用片段')
return { source, slices }
}
export type BrandKnowledgeUploadedPreviewSlice = {
buffer: Buffer
declaredContentType?: string | null
}
export async function validateBrandKnowledgeUploadedPreviewSlices(
uploadedSlices: BrandKnowledgeUploadedPreviewSlice[],
) {
if (uploadedSlices.length < 1 || uploadedSlices.length > BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_COUNT) {
throw new Error(`预览图切片数量需在 1 至 ${BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_COUNT} 片之间`)
}
const totalBytes = uploadedSlices.reduce((sum, slice) => sum + slice.buffer.length, 0)
if (totalBytes > BRAND_KNOWLEDGE_MAX_PREVIEW_IMAGE_BYTES) {
throw new Error('预览图切片总大小不能超过 50MB')
}
return withImageProcessingSlot(async () => {
const slices: Array<{ buffer: Buffer; width: number; height: number }> = []
let expectedWidth = 0
for (let index = 0; index < uploadedSlices.length; index += 1) {
const uploaded = uploadedSlices[index]
if (uploaded.buffer.length < 1 || uploaded.buffer.length > BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_BYTES) {
throw new Error(`预览图第 ${index + 1} 片大小无效`)
}
if (normalizeContentType(uploaded.declaredContentType) !== 'image/webp') {
throw new Error(`预览图第 ${index + 1} 片只支持 WebP 格式`)
}
let metadata: sharp.Metadata
try {
metadata = await sharp(uploaded.buffer, {
animated: true,
failOn: 'error',
limitInputPixels: BRAND_KNOWLEDGE_PREVIEW_OUTPUT_WIDTH * BRAND_KNOWLEDGE_PREVIEW_SLICE_HEIGHT,
sequentialRead: true,
}).metadata()
} catch {
throw new Error(`预览图第 ${index + 1} 片无法解码`)
}
const width = metadata.width || 0
const height = metadata.height || 0
if (metadata.format !== 'webp' || Number(metadata.pages || 1) > 1) {
throw new Error(`预览图第 ${index + 1} 片不是有效的静态 WebP 图片`)
}
if (width < 1 || width > BRAND_KNOWLEDGE_PREVIEW_OUTPUT_WIDTH
|| height < 1 || height > BRAND_KNOWLEDGE_PREVIEW_SLICE_HEIGHT) {
throw new Error(`预览图第 ${index + 1} 片尺寸无效`)
}
if (index === 0) expectedWidth = width
if (width !== expectedWidth) throw new Error('预览图切片宽度不一致')
if (index < uploadedSlices.length - 1 && height !== BRAND_KNOWLEDGE_PREVIEW_SLICE_HEIGHT) {
throw new Error(`预览图第 ${index + 1} 片高度应为 ${BRAND_KNOWLEDGE_PREVIEW_SLICE_HEIGHT}px`)
}
slices.push({ buffer: uploaded.buffer, width, height })
}
return { slices, totalBytes }
})
}